From c8fe28b07b8d072b3c955a9acecc1eca94bb687f Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 04:51:09 +0200 Subject: [PATCH 01/21] Add editorconfig and analyzers, format backend code --- backend/.editorconfig | 65 +++ .../Albums/AddTrackToAlbum.cs | 137 +++-- .../Musify.Application/Albums/CreateAlbum.cs | 215 ++++---- .../Musify.Application/Albums/DeleteAlbum.cs | 75 ++- .../Musify.Application/Albums/GetAlbumById.cs | 51 +- .../Albums/GetAlbumCover.cs | 69 ++- .../Albums/GetAlbumTracks.cs | 61 ++- .../Musify.Application/Albums/GetAlbums.cs | 92 ++-- .../Albums/GetAlbumsByUserId.cs | 69 ++- .../Albums/GetRecentlyListenedAlbums.cs | 91 ++-- .../Albums/RemoveTrackFromAlbum.cs | 93 ++-- .../Albums/RequestAlbumPictureUpload.cs | 165 +++--- .../Responses/AlbumApplicationResponse.cs | 71 ++- .../Responses/AlbumPictureUploadResponse.cs | 21 +- .../Responses/AlbumSearchItemResponse.cs | 9 +- .../Albums/Responses/AlbumsSearchResponse.cs | 21 +- .../Musify.Application/Albums/UpdateAlbum.cs | 261 +++++----- .../Core/Musify.Application/AssemblyInfo.cs | 4 +- .../Configuration/AlbumConfiguration.cs | 99 ++-- .../ApplicationStorageConfiguration.cs | 17 +- .../Configuration/GetPlaybackPublicConfig.cs | 23 +- .../Configuration/MixConfiguration.cs | 15 +- .../Configuration/PlayListConfiguration.cs | 101 ++-- .../Configuration/PlaybackConfiguration.cs | 21 +- .../Responses/PlaybackPublicConfigResponse.cs | 7 +- .../StreamGatewayConfiguration.cs | 19 +- .../Configuration/TrackConfiguration.cs | 145 +++--- .../UploadIntentConfiguration.cs | 53 +- .../Contracts/AudioTranscodeResult.cs | 7 +- .../Contracts/IAudioTranscoderService.cs | 15 +- .../Musify.Application/Contracts/IDatabase.cs | 43 +- .../Contracts/IDatabaseTransaction.cs | 9 +- .../Musify.Application/Contracts/IEventBus.cs | 11 +- .../Contracts/IPictureService.cs | 11 +- .../Contracts/IStorageService.cs | 35 +- .../Contracts/IStreamTicketService.cs | 14 +- .../Contracts/ObjectMetaData.cs | 7 +- .../Contracts/StorageObject.cs | 7 +- .../Events/AlbumPictureProcessed.cs | 7 +- .../Events/AlbumPictureProcessingFailed.cs | 7 +- .../Events/AlbumProcessingFailed.cs | 13 +- .../Events/CreateAlbumResourcesEvent.cs | 23 +- .../Events/CreatePlayListResourcesEvent.cs | 23 +- .../Events/CreateTrackResourcesEvent.cs | 31 +- .../Events/DeletePlayListEvent.cs | 11 +- .../Events/DeleteTrackEvent.cs | 11 +- .../Events/PlayListPictureProcessed.cs | 7 +- .../Events/PlayListPictureProcessingFailed.cs | 7 +- .../Events/PlayListProcessingFailed.cs | 13 +- .../Events/TrackAudioProcessed.cs | 7 +- .../Events/TrackAudioProcessingFailed.cs | 7 +- .../Events/TrackPictureProcessed.cs | 7 +- .../Events/TrackPictureProcessingFailed.cs | 7 +- .../Events/TrackProcessingFailed.cs | 15 +- .../Events/UpdateAlbumPictureEvent.cs | 19 +- .../Events/UpdateAlbumPictureSourceEvent.cs | 23 +- .../Events/UpdatePlayListPictureEvent.cs | 19 +- .../UpdatePlayListPictureSourceEvent.cs | 23 +- .../Events/UpdateTrackAudioEvent.cs | 17 +- .../Events/UpdateTrackPictureEvent.cs | 19 +- .../Genres/GetAvailableGenres.cs | 33 +- .../Musify.Application/Genres/GetGenres.cs | 41 +- .../Likes/GetLikedTracks.cs | 51 +- .../Likes/ToggleTrackLike.cs | 85 ++- .../Mixes/GenerateMixesForUser.cs | 237 +++++---- .../Musify.Application/Mixes/GetMixById.cs | 35 +- .../Mixes/GetMixesByUserId.cs | 47 +- .../Musify.Application/Mixes/MixItemMapper.cs | 57 +- .../Mixes/Responses/MixApplicationResponse.cs | 19 +- .../Responses/MixItemApplicationResponse.cs | 17 +- .../PlayLists/AddTrackToPlayList.cs | 123 +++-- .../PlayLists/CreatePlayList.cs | 241 +++++---- .../PlayLists/DeletePlayList.cs | 95 ++-- .../PlayLists/GetPlayListById.cs | 63 +-- .../PlayLists/GetPlayListCover.cs | 69 ++- .../PlayLists/GetPlayListTracks.cs | 61 ++- .../PlayLists/GetPlayLists.cs | 59 ++- .../PlayLists/GetPlayListsByUserId.cs | 95 ++-- .../PlayLists/RemoveTrackFromPlayList.cs | 73 ++- .../PlayLists/RequestPlayListPictureUpload.cs | 163 +++--- .../Responses/PlayListApplicationResponse.cs | 61 ++- .../PlayListPictureUploadResponse.cs | 21 +- .../PlayLists/UpdatePlayList.cs | 267 +++++----- .../Services/UploadIntentValidator.cs | 2 +- .../Musify.Application/Shared/ImageSize.cs | 13 +- .../Shared/PaginatedResponse.cs | 39 +- .../Musify.Application/Shared/StorageKey.cs | 15 +- .../Musify.Application/Tracks/CreateTrack.cs | 289 ++++++----- .../Tracks/DeleteStaleUncountedListens.cs | 39 +- .../Musify.Application/Tracks/DeleteTrack.cs | 116 ++--- .../Tracks/GetLastTrackListenedByUserId.cs | 41 +- .../Tracks/GetListeningHistory.cs | 57 +- .../Musify.Application/Tracks/GetTrackById.cs | 35 +- .../Tracks/GetTrackCover.cs | 79 ++- .../Tracks/GetTrackStream.cs | 67 ++- .../Musify.Application/Tracks/GetTracks.cs | 88 ++-- .../Tracks/GetTracksByUserId.cs | 77 ++- .../Tracks/ListeningRules.cs | 23 +- .../Tracks/RecordListeningProgress.cs | 57 +- .../Tracks/RequestTrackUploadUrls.cs | 241 +++++---- .../Responses/TrackApplicationResponse.cs | 57 +- .../Responses/TrackSearchItemResponse.cs | 9 +- .../Tracks/Responses/TrackStreamResponse.cs | 15 +- .../Responses/TrackUploadUrlsResponse.cs | 31 +- .../Tracks/Responses/TracksSearchResponse.cs | 21 +- .../Musify.Application/Users/CreateUser.cs | 77 ++- .../Musify.Application/Users/FollowUser.cs | 73 ++- .../Users/FollowVisibility.cs | 39 +- .../Users/GetListeningStats.cs | 81 ++- .../Musify.Application/Users/GetUserById.cs | 33 +- .../Users/GetUserFollowers.cs | 57 +- .../Users/GetUserFollowing.cs | 53 +- .../Users/GetUserProfile.cs | 121 +++-- .../Core/Musify.Application/Users/GetUsers.cs | 63 ++- .../Users/IsFollowingUser.cs | 21 +- .../Responses/UserApplicationResponse.cs | 43 +- .../Users/Responses/UserSummaryResponse.cs | 19 +- .../Core/Musify.Application/Users/SyncUser.cs | 79 ++- .../Musify.Application/Users/UnfollowUser.cs | 39 +- .../Musify.Domain/Abstractions/IAuditable.cs | 13 +- backend/Core/Musify.Domain/Entities/Album.cs | 55 +- .../Musify.Domain/Entities/AlbumHasTrack.cs | 37 +- .../Entities/ListeningHistory.cs | 45 +- backend/Core/Musify.Domain/Entities/Mix.cs | 47 +- .../Core/Musify.Domain/Entities/MixItem.cs | 35 +- .../Core/Musify.Domain/Entities/PlayList.cs | 61 ++- .../Entities/PlayListHasTrack.cs | 41 +- backend/Core/Musify.Domain/Entities/Track.cs | 67 ++- .../Core/Musify.Domain/Entities/TrackLike.cs | 35 +- .../Core/Musify.Domain/Entities/TrackTag.cs | 25 +- backend/Core/Musify.Domain/Entities/Upload.cs | 37 +- .../Musify.Domain/Entities/UploadIntent.cs | 63 ++- backend/Core/Musify.Domain/Entities/User.cs | 57 +- .../Core/Musify.Domain/Entities/UserFollow.cs | 35 +- .../Musify.Domain/Entities/UserHasTrack.cs | 31 +- .../ValueObjects/AlbumPictures.cs | 23 +- .../Core/Musify.Domain/ValueObjects/Genre.cs | 59 ++- .../ValueObjects/GenreCompatibility.cs | 73 +-- .../ValueObjects/LifeCycleStatus.cs | 13 +- .../ValueObjects/PlayListPictures.cs | 23 +- .../ValueObjects/PlaylistVisibility.cs | 11 +- .../ValueObjects/ProcessingStatus.cs | 17 +- .../Musify.Domain/ValueObjects/TrackAudio.cs | 29 +- .../ValueObjects/TrackPictures.cs | 27 +- .../ValueObjects/UploadIntentPurpose.cs | 15 +- .../ValueObjects/UploadIntentStatus.cs | 13 +- .../Musify.Domain/ValueObjects/UploadState.cs | 15 +- .../Configuration/AudioConfiguration.cs | 57 +- .../Configuration/DatabaseConfiguration.cs | 17 +- .../InfrastructureStorageConfiguration.cs | 31 +- .../Configuration/MassTransitConfiguration.cs | 21 +- .../OpenTelemetryConfiguration.cs | 17 +- .../StreamTicketConfiguration.cs | 25 +- .../Configuration/WorkerConfiguration.cs | 23 +- .../Jobs/DailyMixGenerationJob.cs | 47 +- .../Jobs/ListeningHistoryCleanupJob.cs | 11 +- .../Jobs/TemporalUploadsCleanupJob.cs | 111 ++-- .../Jobs/UploadIntentExpirationJob.cs | 99 ++-- .../Albums/MarkAlbumAsFailedActivity.cs | 35 +- ...lishAlbumPictureProcessingEventActivity.cs | 43 +- .../GenerateAudioWorkflowPathsActivity.cs | 103 ++-- .../Audio/TranscodeAudioActivity.cs | 152 +++--- .../Audio/UpdateTrackAudioActivity.cs | 183 ++++--- .../Files/CopyFileInBucketActivity.cs | 91 ++-- .../Files/DownloadFileFromBucketActivity.cs | 91 ++-- .../Files/DownloadFileFromUrlActivity.cs | 103 ++-- .../Files/RemoveFileFromBucketActivity.cs | 75 ++- .../Files/TransferFilesToBucketActivity.cs | 91 ++-- .../Files/UploadFileToBucketActivity.cs | 93 ++-- .../GeneratePictureWorkflowPathsActivity.cs | 81 ++- .../Pictures/ResizePictureActivity.cs | 111 ++-- .../Pictures/UpdateAlbumPictureActivity.cs | 169 +++--- .../Pictures/UpdatePlayListPictureActivity.cs | 179 ++++--- .../Pictures/UpdateTrackPictureActivity.cs | 165 +++--- .../PlayLists/DeletePlayListFromDbActivity.cs | 43 +- .../PlayLists/MarkPlayListAsFailedActivity.cs | 35 +- .../MarkPlayListAsRemovingActivity.cs | 47 +- ...hPlayListPictureProcessingEventActivity.cs | 43 +- .../Tracks/DeleteTrackFromDbActivity.cs | 43 +- .../Tracks/MarkTrackAsFailedActivity.cs | 35 +- .../Tracks/MarkTrackAsRemovingActivity.cs | 47 +- .../PublishTrackProcessingEventsActivity.cs | 59 ++- .../ConsumeUploadIntentsActivity.cs | 77 ++- .../ConsumeUploadIntentsArguments.cs | 9 +- .../Arguments/CopyFileInBucketArguments.cs | 15 +- .../DeletePlayListFromDbArguments.cs | 9 +- .../Arguments/DeleteTrackFromDbArguments.cs | 9 +- .../DownloadFileFromBucketArguments.cs | 13 +- .../Arguments/DownloadFileFromUrlArguments.cs | 11 +- .../GenerateAudioWorkflowPathsArguments.cs | 13 +- .../GeneratePictureWorkflowPathsArguments.cs | 11 +- .../Arguments/MarkAlbumAsFailedArguments.cs | 7 +- .../MarkPlayListAsFailedArguments.cs | 7 +- .../MarkPlayListAsRemovingArguments.cs | 9 +- .../Arguments/MarkTrackAsFailedArguments.cs | 7 +- .../Arguments/MarkTrackAsRemovingArguments.cs | 9 +- ...ishAlbumPictureProcessingEventArguments.cs | 19 +- ...PlayListPictureProcessingEventArguments.cs | 19 +- .../PublishTrackProcessingEventsArguments.cs | 23 +- .../RemoveFileFromBucketArguments.cs | 11 +- .../Arguments/ResizePictureLocalArguments.cs | 15 +- .../Arguments/TranscodeAudioArguments.cs | 13 +- .../TransferFilesToBucketArguments.cs | 13 +- .../Arguments/UpdateAlbumPictureArguments.cs | 17 +- .../UpdatePlayListPictureArguments.cs | 17 +- .../Arguments/UpdateTrackAudioArguments.cs | 13 +- .../Arguments/UpdateTrackPictureArguments.cs | 17 +- .../Arguments/UploadFileToBucketArguments.cs | 13 +- .../AlbumProcessingFailedConsumer.cs | 43 +- .../Consumers/CreateAlbumConsumer.cs | 24 +- .../Consumers/CreatePlayListConsumer.cs | 24 +- .../Consumers/CreateTrackConsumer.cs | 24 +- .../Consumers/DeletePlayListConsumer.cs | 32 +- .../Consumers/DeleteTrackConsumer.cs | 24 +- .../MassTransit/Consumers/EndpointHelper.cs | 13 +- .../PlayListProcessingFailedConsumer.cs | 43 +- .../Consumers/ProcessingSlipFaultConsumer.cs | 49 +- .../Consumers/RoutingSlipCleanUpConsumer.cs | 79 ++- .../TrackProcessingFailedConsumer.cs | 57 +- .../Consumers/UpdateAlbumPictureConsumer.cs | 24 +- .../UpdateAlbumPictureSourceConsumer.cs | 24 +- .../UpdatePlayListPictureConsumer.cs | 28 +- .../UpdatePlayListPictureSourceConsumer.cs | 24 +- .../Consumers/UpdateTrackAudioConsumer.cs | 28 +- .../Consumers/UpdateTrackPictureConsumer.cs | 28 +- .../MassTransitDependencyInjection.Options.cs | 33 +- ...TransitDependencyInjection.Registration.cs | 113 ++-- .../MassTransitDependencyInjection.cs | 231 +++++---- .../Logs/ConsumeUploadIntentsLog.cs | 9 +- .../MassTransit/Logs/CopyFileInBucketLog.cs | 11 +- .../Logs/DownloadFileFromBucketLog.cs | 13 +- .../Logs/DownloadFileFromUrlLog.cs | 11 +- .../Logs/GeneratePictureWorkflowPathsLog.cs | 7 +- .../MassTransit/Logs/ResizePictureLog.cs | 7 +- .../MassTransit/Logs/TranscodeAudioLog.cs | 7 +- .../Logs/TransferFilesToBucketLog.cs | 7 +- .../MassTransit/Logs/UpdateAlbumPictureLog.cs | 17 +- .../Logs/UpdatePlayListPictureLog.cs | 17 +- .../MassTransit/Logs/UpdateTrackAudioLog.cs | 13 +- .../MassTransit/Logs/UpdateTrackPictureLog.cs | 17 +- .../MassTransit/Logs/UploadFileToBucketLog.cs | 7 +- .../MassTransit/MassTransitEventBus.cs | 21 +- .../MassTransit/RoutingSlip/ActivityNames.cs | 87 ++-- .../AlbumPictureSourceRoutingSlipBuilder.cs | 133 +++-- .../AudioWorkflowRoutingSlipBuilder.cs | 101 ++-- .../Builders/CreateTrackRoutingSlipBuilder.cs | 87 ++-- .../DeletePlayListRoutingSlipBuilder.cs | 147 +++--- .../Builders/DeleteTrackRoutingSlipBuilder.cs | 173 ++++--- .../PictureWorkflowRoutingSlipBuilder.cs | 337 ++++++------ ...PlayListPictureSourceRoutingSlipBuilder.cs | 133 +++-- .../RoutingSlip/RoutingSlipVariableNames.cs | 57 +- .../MassTransit/Sagas/AlbumProcessingState.cs | 19 +- .../Sagas/AlbumProcessingStateMachine.cs | 77 ++- .../Sagas/PlayListProcessingState.cs | 19 +- .../Sagas/PlayListProcessingStateMachine.cs | 77 ++- .../MassTransit/Sagas/TrackProcessingState.cs | 25 +- .../Sagas/TrackProcessingStateMachine.cs | 135 +++-- .../OpenTelemetryDependencyInjection.cs | 85 ++- .../Persistence/AuditableEntityInterceptor.cs | 65 ++- .../Persistence/Database.cs | 321 ++++++------ .../DatabaseDependencyInjection.cs | 29 +- .../Persistence/DatabaseDesignTimeFactory.cs | 39 +- .../Services/AudioTranscoderService.cs | 423 ++++++++------- .../Services/PictureService.cs | 53 +- .../Services/ServicesDependencyInjection.cs | 186 ++++--- .../Services/SingleFlightCache.cs | 55 +- .../Services/StorageService.cs | 487 +++++++++--------- .../Services/StreamTicketService.cs | 83 ++- backend/Directory.Build.props | 5 + .../Musify.Api/Authentication/CurrentUser.cs | 2 +- .../Authentication/JwtBearerEventsHandler.cs | 7 +- .../Authentication/JwtBearerOptionsSetup.cs | 2 +- .../Musify.Api/Endpoints/LikeEndpoints.cs | 7 +- .../Musify.Api/Endpoints/PlayListEndpoints.cs | 12 +- .../Musify.Api/Endpoints/TrackEndpoints.cs | 10 +- .../Extensions/ServiceCollectionExtensions.cs | 8 +- .../Musify.Api/Filters/ValidationFilter.cs | 2 +- .../Musify.Api/OpenApi/OpenApiOptionsSetup.cs | 8 +- backend/Hosts/Musify.Api/Program.cs | 6 +- .../Musify.Api/Scalar/ScalarOptionsSetup.cs | 4 +- .../Hosts/Musify.StreamingGateway/Program.cs | 2 +- .../Authentication/CurrentUserTests.cs | 123 +++-- .../Endpoints/AlbumEndpointsTests.cs | 197 ++++--- .../Endpoints/ConfigEndpointsTests.cs | 25 +- .../Endpoints/GenreEndpointsTests.cs | 147 +++--- .../ListeningProgressEndpointsTests.cs | 161 +++--- .../Endpoints/UserEndpointsTests.cs | 187 ++++--- .../Extensions/ErrorOrHttpExtensionsTests.cs | 231 +++++---- .../Filters/ValidationFilterTests.cs | 83 ++- .../TestSupport/ApiTestFixture.cs | 161 +++--- .../TestSupport/FakeAuthenticationHandler.cs | 39 +- .../CreateAlbumRequestValidatorTests.cs | 119 +++-- .../CreateUserRequestValidatorTests.cs | 69 ++- .../AddTrackToAlbumCommandHandlerTests.cs | 161 +++--- .../Albums/CreateAlbumCommandHandlerTests.cs | 105 ++-- .../Albums/DeleteAlbumCommandHandlerTests.cs | 61 ++- .../Albums/GetAlbumByIdQueryHandlerTests.cs | 45 +- .../Albums/GetAlbumCoverQueryHandlerTests.cs | 49 +- .../Albums/GetAlbumTracksQueryHandlerTests.cs | 49 +- .../GetAlbumsByUserIdQueryHandlerTests.cs | 41 +- .../Albums/GetAlbumsQueryHandlerTests.cs | 67 ++- ...RecentlyListenedAlbumsQueryHandlerTests.cs | 121 +++-- ...RemoveTrackFromAlbumCommandHandlerTests.cs | 103 ++-- ...stAlbumPictureUploadCommandHandlerTests.cs | 95 ++-- .../Albums/UpdateAlbumCommandHandlerTests.cs | 131 +++-- .../GetPlaybackPublicConfigTests.cs | 35 +- .../GetAvailableGenresQueryHandlerTests.cs | 41 +- .../Genres/GetGenresQueryHandlerTests.cs | 97 ++-- .../Likes/GetLikedTracksQueryHandlerTests.cs | 75 ++- .../ToggleTrackLikeCommandHandlerTests.cs | 75 ++- ...GenerateMixesForUserCommandHandlerTests.cs | 135 +++-- .../Mixes/GetMixByIdQueryHandlerTests.cs | 67 ++- .../GetMixesByUserIdQueryHandlerTests.cs | 51 +- .../AddTrackToPlayListCommandHandlerTests.cs | 131 +++-- .../CreatePlayListCommandHandlerTests.cs | 157 +++--- .../DeletePlayListCommandHandlerTests.cs | 69 ++- .../GetPlayListByIdQueryHandlerTests.cs | 117 +++-- .../GetPlayListCoverQueryHandlerTests.cs | 69 ++- .../GetPlayListTracksQueryHandlerTests.cs | 49 +- .../GetPlayListsByUserIdQueryHandlerTests.cs | 83 ++- .../GetPlayListsQueryHandlerTests.cs | 39 +- ...oveTrackFromPlayListCommandHandlerTests.cs | 79 ++- ...layListPictureUploadCommandHandlerTests.cs | 95 ++-- .../UpdatePlayListCommandHandlerTests.cs | 193 ++++--- .../Services/TrackStreamIssuerTests.cs | 143 +++-- .../Services/UploadIntentValidatorTests.cs | 203 ++++---- .../Shared/StorageKeyTests.cs | 55 +- .../Shared/TrackApplicationResponseTests.cs | 39 +- .../TestSupport/HandlerTestBase.cs | 35 +- .../TestSupport/TestConfigurations.cs | 29 +- .../TestSupport/TestDatabase.cs | 273 +++++----- .../TestSupport/TestEntities.cs | 363 +++++++------ .../Tracks/CreateTrackCommandHandlerTests.cs | 211 ++++---- ...taleUncountedListensCommandHandlerTests.cs | 41 +- .../Tracks/DeleteTrackCommandHandlerTests.cs | 87 ++-- ...tTrackListenedByUserIdQueryHandlerTests.cs | 51 +- .../GetListeningHistoryQueryHandlerTests.cs | 77 ++- .../Tracks/GetTrackByIdQueryHandlerTests.cs | 71 ++- .../Tracks/GetTrackCoverQueryHandlerTests.cs | 71 ++- .../Tracks/GetTrackStreamQueryHandlerTests.cs | 139 +++-- .../GetTracksByUserIdQueryHandlerTests.cs | 63 ++- .../Tracks/GetTracksQueryHandlerTests.cs | 97 ++-- ...ordListeningProgressCommandHandlerTests.cs | 183 ++++--- ...questTrackUploadUrlsCommandHandlerTests.cs | 103 ++-- .../Users/CreateUserCommandHandlerTests.cs | 49 +- .../Users/FollowUserCommandHandlerTests.cs | 79 ++- .../GetListeningStatsQueryHandlerTests.cs | 179 ++++--- .../Users/GetUserByIdQueryHandlerTests.cs | 37 +- .../GetUserFollowersQueryHandlerTests.cs | 133 +++-- .../GetUserFollowingQueryHandlerTests.cs | 109 ++-- .../Users/GetUserProfileQueryHandlerTests.cs | 115 ++--- .../Users/GetUsersQueryHandlerTests.cs | 61 ++- .../Users/IsFollowingUserQueryHandlerTests.cs | 43 +- .../Users/SyncUserCommandHandlerTests.cs | 57 +- .../Users/UnfollowUserCommandHandlerTests.cs | 45 +- .../ValueObjects/GenreCompatibilityTests.cs | 75 ++- .../ValueObjects/TrackAudioTests.cs | 33 +- .../ValueObjects/TrackPicturesTests.cs | 27 +- .../AuditableEntityInterceptorTests.cs | 71 ++- .../Persistence/DatabaseTests.cs | 181 ++++--- .../Services/PictureServiceTests.cs | 77 ++- .../Services/SingleFlightCacheTests.cs | 121 +++-- .../Services/StorageServiceTests.cs | 253 +++++---- .../Services/StreamTicketServiceTests.cs | 185 ++++--- .../TestSupport/InfrastructureTestFixture.cs | 171 +++--- .../Authentication/TicketValidatorTests.cs | 283 +++++----- .../TicketValidationMiddlewareTests.cs | 335 ++++++------ 367 files changed, 12179 insertions(+), 12459 deletions(-) create mode 100644 backend/.editorconfig 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()); } } From c8e9c7790e666e08d8a08867b5dfba2b5512b95a Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 05:00:21 +0200 Subject: [PATCH 02/21] Close data leaks: remove anonymous user creation, hide private playlists, validate uploads and clamp gateway ranges --- .../Musify.Application/Albums/CreateAlbum.cs | 2 +- .../Musify.Application/Albums/UpdateAlbum.cs | 2 +- .../PlayLists/CreatePlayList.cs | 2 +- .../PlayLists/GetPlayListTracks.cs | 9 +- .../PlayLists/GetPlayLists.cs | 42 ---- .../PlayLists/GetPlayListsByUserId.cs | 10 +- .../PlayLists/UpdatePlayList.cs | 2 +- .../Services/UploadIntentValidator.cs | 4 + .../Musify.Application/Tracks/CreateTrack.cs | 7 +- .../Musify.Application/Users/CreateUser.cs | 58 ----- .../Users/CreateUserRequest.cs | 7 - .../Musify.Api/Endpoints/PlayListEndpoints.cs | 26 +- .../Musify.Api/Endpoints/UserEndpoints.cs | 35 +-- ...questAlbumPictureUploadRequestValidator.cs | 10 +- ...stPlayListPictureUploadRequestValidator.cs | 10 +- .../RequestTrackUploadUrlsRequestValidator.cs | 17 +- .../Hosts/Musify.Api/Validators/Uploads.cs | 42 ++++ .../Users/CreateUserRequestValidator.cs | 25 -- .../Middleware/TicketValidationMiddleware.cs | 14 +- .../Endpoints/AlbumEndpointsTests.cs | 10 +- .../ListeningProgressEndpointsTests.cs | 10 +- .../Endpoints/UserEndpointsTests.cs | 40 +-- .../TestSupport/ApiTestFixture.cs | 17 ++ .../CreateUserRequestValidatorTests.cs | 51 ---- .../Validators/UploadRequestValidatorTests.cs | 49 ++++ .../GetPlayListTracksQueryHandlerTests.cs | 27 +- .../GetPlayListsByUserIdQueryHandlerTests.cs | 23 +- .../GetPlayListsQueryHandlerTests.cs | 31 --- .../Services/UploadIntentValidatorTests.cs | 25 +- .../Tracks/CreateTrackCommandHandlerTests.cs | 13 + .../Users/CreateUserCommandHandlerTests.cs | 40 --- .../TicketValidationMiddlewareTests.cs | 33 +++ web-player/src/lib/api/schema.d.ts | 232 ++++++------------ .../routes/(app)/user/[id]/+page.server.ts | 2 +- 34 files changed, 374 insertions(+), 553 deletions(-) delete mode 100644 backend/Core/Musify.Application/PlayLists/GetPlayLists.cs delete mode 100644 backend/Core/Musify.Application/Users/CreateUser.cs delete mode 100644 backend/Hosts/Musify.Api/DataTransferObjects/Users/CreateUserRequest.cs create mode 100644 backend/Hosts/Musify.Api/Validators/Uploads.cs delete mode 100644 backend/Hosts/Musify.Api/Validators/Users/CreateUserRequestValidator.cs delete mode 100644 backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs create mode 100644 backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs delete mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs delete mode 100644 backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index 4646035..4e124a1 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -43,7 +43,7 @@ public class CreateAlbumCommandHandler( } var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken); + uploadIntentConfiguration, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, cancellationToken); if (pictureIntent.IsError) return pictureIntent.Errors; diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index 1ceb9d9..d2984a5 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -62,7 +62,7 @@ public class UpdateAlbumCommandHandler( { var validation = await uploadIntentValidator.ValidateAndLoadAsync( uploadIntentConfiguration, - request.NewPictureIntentId.Value, request.UserId, cancellationToken); + request.NewPictureIntentId.Value, request.UserId, UploadIntentPurpose.AlbumPicture, cancellationToken); if (validation.IsError) return validation.Errors; diff --git a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index 3481922..c2bb640 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -92,7 +92,7 @@ public class CreatePlayListCommandHandler( } var validation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, cancellationToken); + uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, cancellationToken); if (validation.IsError) return validation.Errors; diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index 1850462..6a299b8 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; using X.PagedList; using X.PagedList.EF; @@ -12,7 +13,8 @@ namespace Musify.Application.PlayLists; public record GetPlayListTracksQuery( Guid PlayListId, int PageNumber, - int PageSize) + int PageSize, + long? ViewerId = null) : IQuery>>; public class GetPlayListTracksQueryHandler(IDatabase database) @@ -22,7 +24,10 @@ public class GetPlayListTracksQueryHandler(IDatabase database) { var playListExists = await database.PlayLists .AsNoTracking() - .AnyAsync(p => p.Id == request.PlayListId, cancellationToken); + .AnyAsync( + p => p.Id == request.PlayListId + && (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId), + cancellationToken); if (!playListExists) return Error.NotFound(); diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs b/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs deleted file mode 100644 index 1b22591..0000000 --- a/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs +++ /dev/null @@ -1,42 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Musify.Application.Contracts; -using Musify.Application.PlayLists.Responses; -using Musify.Application.Shared; -using X.PagedList; -using X.PagedList.EF; - -namespace Musify.Application.PlayLists; - -public record GetPlayListsQuery(int PageNumber, int PageSize) - : IQuery>>; - -public class GetPlayListsQueryHandler(IDatabase database) - : IQueryHandler>> -{ - public async ValueTask>> Handle(GetPlayListsQuery request, CancellationToken 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 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/GetPlayListsByUserId.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs index 57f5817..0716ce7 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -15,7 +15,7 @@ public record GetPlayListsByUserIdQuery( string? Name, int PageNumber, int PageSize, - bool OnlyPublic = false) + long? ViewerId = null) : IQuery>>; public class GetPlayListsByUserIdQueryHandler(IDatabase database) @@ -25,12 +25,8 @@ public class GetPlayListsByUserIdQueryHandler(IDatabase database) { var playListsQuery = database.PlayLists .AsNoTracking() - .Where(p => p.UserId == request.UserId); - - if (request.OnlyPublic) - { - playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public); - } + .Where(p => p.UserId == request.UserId + && (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId)); if (!string.IsNullOrEmpty(request.Name)) { diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index b5effd5..a791752 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -70,7 +70,7 @@ public class UpdatePlayListCommandHandler( { var validation = await uploadIntentValidator.ValidateAndLoadAsync( uploadIntentConfiguration, - request.NewPictureIntentId.Value, request.UserId, cancellationToken); + request.NewPictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, cancellationToken); if (validation.IsError) return validation.Errors; diff --git a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs index e37abfb..954d769 100644 --- a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs @@ -37,6 +37,7 @@ public sealed class UploadIntentValidator( UploadIntentConfiguration config, Guid intentId, long userId, + UploadIntentPurpose purpose, CancellationToken cancellationToken) { var intent = await database.UploadIntents @@ -45,6 +46,9 @@ public sealed class UploadIntentValidator( if (intent == null || intent.UserId != userId) return Error.NotFound(description: "Upload intent not found or not accessible."); + if (intent.Purpose != purpose) + return Error.Validation(description: "Upload intent was issued for a different purpose."); + if (intent.IsConsumed) return Error.Conflict(description: "Upload intent has already been consumed."); diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index 139cc53..d16e848 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -59,15 +59,18 @@ public class CreateTrackCommandHandler( return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible."); } + if (request.PictureIntentId == request.AudioIntentId) + return Error.Validation(description: "Picture and audio must use different upload intents."); + var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( uploadIntentConfiguration, - request.PictureIntentId, request.UserId, cancellationToken); + request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken); if (pictureValidation.IsError) return pictureValidation.Errors; var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync( uploadIntentConfiguration, - request.AudioIntentId, request.UserId, cancellationToken); + request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken); if (audioValidation.IsError) return audioValidation.Errors; diff --git a/backend/Core/Musify.Application/Users/CreateUser.cs b/backend/Core/Musify.Application/Users/CreateUser.cs deleted file mode 100644 index 2eaa279..0000000 --- a/backend/Core/Musify.Application/Users/CreateUser.cs +++ /dev/null @@ -1,58 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Application.Users.Responses; -using Musify.Domain.Entities; - -namespace Musify.Application.Users; - -public record CreateUserCommand(long Id, - string Name, - string? FirstName, - string? SecondName) - : ICommand> -{ - public static User ToEntity(CreateUserCommand command) - { - return new User - { - 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 async ValueTask> Handle(CreateUserCommand request, CancellationToken cancellationToken) - { - 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"); - } - - 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}"); - } - - logger.LogInformation("Created user {UserId}", newUser.Id); - return UserApplicationResponse.FromEntity(newUser); - } -} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Users/CreateUserRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Users/CreateUserRequest.cs deleted file mode 100644 index 9a6155c..0000000 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Users/CreateUserRequest.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Musify.Api.DataTransferObjects.Users; - -public record CreateUserRequest( - long Id, - string Name, - string? FirstName, - string? SecondName); diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index be41042..2fd1d69 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -19,11 +19,6 @@ public static class PlayListEndpoints var group = app.MapGroup("/playlists") .WithTags("PlayLists"); - group.MapGet("/", GetPlayLists) - .WithName("GetPlayLists") - .WithSummary("Get Paginated PlayLists.") - .Produces>(); - group.MapGet("/{id}", GetPlayListById) .WithName("GetPlayListById") .WithSummary("Get A PlayList By Id.") @@ -32,7 +27,7 @@ public static class PlayListEndpoints group.MapGet("/users/{userId}", GetPlayListsByUserId) .WithName("GetPlayListsByUserId") - .WithSummary("Get Paginated PlayLists For A User.") + .WithSummary("Get Paginated PlayLists For A User. Private PlayLists Are Only Returned To Their Owner.") .Produces>(); group.MapGet("/{playlistId}/tracks", GetPlayListTracks) @@ -105,16 +100,6 @@ public static class PlayListEndpoints return app; } - private static async Task GetPlayLists( - IMediator mediator, - CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 10) - { - var result = await mediator.Send(new GetPlayListsQuery(pageNumber, pageSize), cancellationToken); - return result.ToHttpResult(); - } - private static async Task GetPlayListById( IMediator mediator, CurrentUser currentUser, @@ -127,25 +112,26 @@ public static class PlayListEndpoints private static async Task GetPlayListsByUserId( IMediator mediator, + CurrentUser currentUser, long userId, CancellationToken cancellationToken, string? name, int pageNumber = 1, - int pageSize = 10, - bool onlyPublic = false) + int pageSize = 10) { - var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize, onlyPublic), cancellationToken); + var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize, currentUser.Id), cancellationToken); return result.ToHttpResult(); } private static async Task GetPlayListTracks( IMediator mediator, + CurrentUser currentUser, Guid playlistId, CancellationToken cancellationToken, int pageNumber = 1, int pageSize = 10) { - var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, pageNumber, pageSize, currentUser.Id), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs index 63448eb..3c0029a 100644 --- a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -1,8 +1,6 @@ using Mediator; using Musify.Api.Authentication; -using Musify.Api.DataTransferObjects.Users; using Musify.Api.Extensions; -using Musify.Api.Filters; using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; @@ -38,7 +36,10 @@ public static class UserEndpoints group.MapGet("/{id}/listening-stats", GetListeningStats) .WithName("GetListeningStats") .WithSummary("Get A User'S Listening Stats For The Current Week And Streak.") - .Produces(); + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); group.MapGet("/{id}/last-listened-track", GetLastTrackListenedByUserId) .WithName("GetLastTrackListenedByUserId") @@ -88,14 +89,6 @@ public static class UserEndpoints .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized); - group.MapPost("/", CreateUser) - .WithName("CreateUser") - .WithSummary("Create A New User.") - .AddEndpointFilter>() - .Produces(StatusCodes.Status201Created) - .ProducesValidationProblem() - .Produces(StatusCodes.Status409Conflict); - return app; } @@ -128,12 +121,16 @@ public static class UserEndpoints return await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken); } - private static async Task GetListeningStats( + private static async Task GetListeningStats( IMediator mediator, + CurrentUser currentUser, long id, CancellationToken cancellationToken) { - return await mediator.Send(new GetListeningStatsQuery(id), cancellationToken); + if (currentUser.RequiredId != id) + return Results.StatusCode(StatusCodes.Status403Forbidden); + + return Results.Ok(await mediator.Send(new GetListeningStatsQuery(id), cancellationToken)); } private static async Task GetLastTrackListenedByUserId( @@ -207,16 +204,4 @@ public static class UserEndpoints var result = await mediator.Send(new UnfollowUserCommand(currentUser.RequiredId, id), cancellationToken); return result.ToHttpResult(); } - - private static async Task CreateUser( - IMediator mediator, - CreateUserRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new CreateUserCommand(request.Id, request.Name, request.FirstName, request.SecondName), - cancellationToken); - - return result.ToCreatedResult(user => $"/users/{user.Id}"); - } } diff --git a/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs index d747056..0db9287 100644 --- a/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs @@ -1,20 +1,20 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Albums; +using Musify.Application.Configuration; namespace Musify.Api.Validators.Albums; public sealed class RequestAlbumPictureUploadRequestValidator : AbstractValidator { - public RequestAlbumPictureUploadRequestValidator() + public RequestAlbumPictureUploadRequestValidator(UploadIntentConfiguration uploadConfiguration) { RuleFor(x => x.FileType) - .NotEmpty(); + .MustBeFileType(Uploads.PictureFileTypes); RuleFor(x => x.ContentType) - .NotEmpty(); + .MustBeContentType(Uploads.PictureContentTypes); RuleFor(x => x.ExpectedSizeBytes) - .GreaterThan(0) - .When(x => x.ExpectedSizeBytes.HasValue); + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); } } diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs index 94c7529..7da850a 100644 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs @@ -1,20 +1,20 @@ using FluentValidation; using Musify.Api.DataTransferObjects.PlayLists; +using Musify.Application.Configuration; namespace Musify.Api.Validators.PlayLists; public sealed class RequestPlayListPictureUploadRequestValidator : AbstractValidator { - public RequestPlayListPictureUploadRequestValidator() + public RequestPlayListPictureUploadRequestValidator(UploadIntentConfiguration uploadConfiguration) { RuleFor(x => x.FileType) - .NotEmpty(); + .MustBeFileType(Uploads.PictureFileTypes); RuleFor(x => x.ContentType) - .NotEmpty(); + .MustBeContentType(Uploads.PictureContentTypes); RuleFor(x => x.ExpectedSizeBytes) - .GreaterThan(0) - .When(x => x.ExpectedSizeBytes.HasValue); + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); } } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs index e84a60b..ee2834e 100644 --- a/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs @@ -1,30 +1,29 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Tracks; +using Musify.Application.Configuration; namespace Musify.Api.Validators.Tracks; public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator { - public RequestTrackUploadUrlsRequestValidator() + public RequestTrackUploadUrlsRequestValidator(UploadIntentConfiguration uploadConfiguration) { RuleFor(x => x.PictureFileType) - .NotEmpty(); + .MustBeFileType(Uploads.PictureFileTypes); RuleFor(x => x.PictureContentType) - .NotEmpty(); + .MustBeContentType(Uploads.PictureContentTypes); RuleFor(x => x.AudioFileType) - .NotEmpty(); + .MustBeFileType(Uploads.AudioFileTypes); RuleFor(x => x.AudioContentType) - .NotEmpty(); + .MustBeContentType(Uploads.AudioContentTypes); RuleFor(x => x.ExpectedPictureSizeBytes) - .GreaterThan(0) - .When(x => x.ExpectedPictureSizeBytes.HasValue); + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); RuleFor(x => x.ExpectedAudioSizeBytes) - .GreaterThan(0) - .When(x => x.ExpectedAudioSizeBytes.HasValue); + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); } } diff --git a/backend/Hosts/Musify.Api/Validators/Uploads.cs b/backend/Hosts/Musify.Api/Validators/Uploads.cs new file mode 100644 index 0000000..39ad9fc --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Uploads.cs @@ -0,0 +1,42 @@ +using FluentValidation; + +namespace Musify.Api.Validators; + +public static class Uploads +{ + public static readonly IReadOnlySet PictureFileTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "jpg", "jpeg", "png", "webp" }; + + public static readonly IReadOnlySet PictureContentTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp" }; + + public static readonly IReadOnlySet AudioFileTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "mp3", "wav", "flac", "ogg", "m4a", "aac" }; + + public static readonly IReadOnlySet AudioContentTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/wave", "audio/flac", "audio/x-flac", + "audio/ogg", "audio/mp4", "audio/x-m4a", "audio/m4a", "audio/aac" + }; + + public static IRuleBuilderOptions MustBeFileType( + this IRuleBuilder rule, IReadOnlySet allowed) => + rule + .NotEmpty() + .Length(1, 10) + .Matches("^[A-Za-z0-9]+$").WithMessage("'{PropertyName}' must contain only letters and digits.") + .Must(allowed.Contains).WithMessage("'{PropertyName}' must be one of: " + string.Join(", ", allowed) + "."); + + public static IRuleBuilderOptions MustBeContentType( + this IRuleBuilder rule, IReadOnlySet allowed) => + rule + .NotEmpty() + .Must(allowed.Contains).WithMessage("'{PropertyName}' is not an allowed content type."); + + public static IRuleBuilderOptions MustBeValidUploadSize( + this IRuleBuilder rule, long maxUploadBytes) => + rule + .Must(size => size is null || (size > 0 && size <= maxUploadBytes)) + .WithMessage($"'{{PropertyName}}' must be greater than 0 and at most {maxUploadBytes} bytes."); +} diff --git a/backend/Hosts/Musify.Api/Validators/Users/CreateUserRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Users/CreateUserRequestValidator.cs deleted file mode 100644 index bd8de92..0000000 --- a/backend/Hosts/Musify.Api/Validators/Users/CreateUserRequestValidator.cs +++ /dev/null @@ -1,25 +0,0 @@ -using FluentValidation; -using Musify.Api.DataTransferObjects.Users; - -namespace Musify.Api.Validators.Users; - -public sealed class CreateUserRequestValidator : AbstractValidator -{ - public CreateUserRequestValidator() - { - RuleFor(x => x.Id) - .NotEmpty(); - - RuleFor(x => x.Name) - .NotEmpty() - .MaximumLength(48); - - RuleFor(x => x.FirstName) - .MaximumLength(48) - .When(x => !string.IsNullOrWhiteSpace(x.FirstName)); - - RuleFor(x => x.SecondName) - .MaximumLength(48) - .When(x => !string.IsNullOrWhiteSpace(x.SecondName)); - } -} diff --git a/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs b/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs index 94b9644..89da5f8 100644 --- a/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs +++ b/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs @@ -61,7 +61,7 @@ public sealed class TicketValidationMiddleware( if (validation.MaxBytes is { } maxBytes && !TryClampRange(context, maxBytes)) { logger.LogInformation( - "Rejected media request {ObjectKey}: requested range starts beyond the {MaxBytes}-byte preview limit", + "Rejected media request {ObjectKey}: requested range is a suffix range or starts beyond the {MaxBytes}-byte preview limit", objectKey, maxBytes); context.Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable; context.Response.Headers[HeaderNames.ContentRange] = $"bytes */{maxBytes}"; @@ -81,11 +81,19 @@ public sealed class TicketValidationMiddleware( return true; } - if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range) || range.Ranges.Count != 1) + if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range) + || range.Ranges.Count != 1 + || !string.Equals(range.Unit.ToString(), "bytes", StringComparison.OrdinalIgnoreCase)) + { + context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}"; return true; + } var requested = range.Ranges.Single(); - var start = requested.From ?? 0; + if (requested.From is null) + return false; + + var start = requested.From.Value; if (start >= maxBytes) return false; diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index 92a1e15..49d0115 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -1,7 +1,6 @@ using System.Net; using System.Net.Http.Json; using Musify.Api.DataTransferObjects.Albums; -using Musify.Api.DataTransferObjects.Users; using Musify.Api.Tests.TestSupport; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; @@ -13,14 +12,7 @@ namespace Musify.Api.Tests.Endpoints; [Collection(ApiCollection.Name)] public sealed class AlbumEndpointsTests(ApiTestFixture fixture) { - 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; - } + private Task CreateUserAsync() => fixture.SeedUserAsync(); private async Task CreatePictureIntentAsync(HttpClient client) { diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs index 41f3e4e..2067135 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs @@ -3,7 +3,6 @@ using System.Net.Http.Json; 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; @@ -14,14 +13,7 @@ namespace Musify.Api.Tests.Endpoints; [Collection(ApiCollection.Name)] public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture) { - 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 Task CreateUserAsync() => fixture.SeedUserAsync(); private async Task SeedListenAsync(long userId, double durationSeconds) { diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs index c4df797..9d27991 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs @@ -4,7 +4,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Musify.Api.DataTransferObjects.Users; using Musify.Api.Tests.TestSupport; using Musify.Application.Tracks.Responses; using Musify.Application.Users.Responses; @@ -45,40 +44,13 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture) } [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); - - 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() + public async Task PostUsers_IsNotExposed() { 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 { id = 1, name = "Intruder" }, TestContext.Current.CancellationToken); - 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); - - var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Second", null, null), TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.True(response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed); } [Fact] @@ -86,7 +58,7 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture) { 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); + await fixture.SeedUserAsync(userId, "Findable User"); var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken); @@ -110,7 +82,7 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture) { 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 fixture.SeedUserAsync(userId); await SeedListenAsync(userId, "Older listen", DateTime.UtcNow.AddMinutes(-10)); await SeedListenAsync(userId, "Newer listen", DateTime.UtcNow); @@ -126,7 +98,7 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture) { 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 fixture.SeedUserAsync(userId); var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs index 5d44ceb..e2a5961 100644 --- a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs +++ b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs @@ -44,6 +44,23 @@ public sealed class ApiTestFixture : WebApplicationFactory, IAsyncLifet public HttpClient CreateAnonymousClient() => CreateClient(); + public async Task SeedUserAsync(long? userId = null, string? name = null) + { + var id = userId ?? Random.Shared.NextInt64(1, long.MaxValue); + var userName = name ?? $"user-{id}"; + + using var scope = Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + database.Users.Add(new Musify.Domain.Entities.User + { + Id = id, + Name = userName, + NormalizedName = userName.ToUpperInvariant() + }); + await database.SaveChangesAsync(); + return id; + } + public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user") { var client = CreateClient(); diff --git a/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs deleted file mode 100644 index 36ccb0f..0000000 --- a/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Musify.Api.DataTransferObjects.Users; -using Musify.Api.Validators.Users; -using Xunit; - -namespace Musify.Api.Tests.Validators; - -public sealed class CreateUserRequestValidatorTests -{ - private readonly CreateUserRequestValidator validator = new(); - - [Fact] - public void Validate_ValidRequest_Passes() - { - var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", "Jane", "Doe")); - - Assert.True(result.IsValid); - } - - [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)); - } - - [Fact] - public void Validate_BlankName_Fails() - { - var result = validator.Validate(new CreateUserRequest(1, "", null, null)); - - Assert.False(result.IsValid); - } - - [Fact] - public void Validate_NameOverMaxLength_Fails() - { - var result = validator.Validate(new CreateUserRequest(1, new string('a', 49), null, null)); - - Assert.False(result.IsValid); - } - - [Fact] - public void Validate_OptionalNamesOmitted_Passes() - { - var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", null, null)); - - Assert.True(result.IsValid); - } -} diff --git a/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs new file mode 100644 index 0000000..f671b0e --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs @@ -0,0 +1,49 @@ +using Musify.Api.DataTransferObjects.Albums; +using Musify.Api.DataTransferObjects.Tracks; +using Musify.Api.Validators.Albums; +using Musify.Api.Validators.Tracks; +using Musify.Application.Configuration; +using Xunit; + +namespace Musify.Api.Tests.Validators; + +public sealed class UploadRequestValidatorTests +{ + private readonly UploadIntentConfiguration configuration = new() { MaxUploadBytes = 1_000 }; + + [Fact] + public void PictureUpload_ValidRequest_Passes() + { + var validator = new RequestAlbumPictureUploadRequestValidator(configuration); + + var result = validator.Validate(new RequestAlbumPictureUploadRequest("webp", "image/webp", 500)); + + Assert.True(result.IsValid); + } + + [Theory] + [InlineData("exe", "image/webp", 500L)] + [InlineData("averyveryveryloongextension", "image/webp", 500L)] + [InlineData("we bp", "image/webp", 500L)] + [InlineData("webp", "text/html", 500L)] + [InlineData("webp", "image/webp", 0L)] + [InlineData("webp", "image/webp", 1_001L)] + public void PictureUpload_InvalidRequest_Fails(string fileType, string contentType, long size) + { + var validator = new RequestAlbumPictureUploadRequestValidator(configuration); + + var result = validator.Validate(new RequestAlbumPictureUploadRequest(fileType, contentType, size)); + + Assert.False(result.IsValid); + } + + [Fact] + public void TrackUpload_AudioTypesAreCheckedAgainstTheAudioAllowList() + { + var validator = new RequestTrackUploadUrlsRequestValidator(configuration); + + Assert.True(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg", 10, 900)).IsValid); + Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "png", "image/png")).IsValid); + Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("mp3", "audio/mpeg", "mp3", "audio/mpeg")).IsValid); + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs index 5f181b3..013402f 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs @@ -2,6 +2,7 @@ using ErrorOr; using Musify.Application.PlayLists; using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; using Xunit; namespace Musify.Application.Tests.PlayLists; @@ -22,7 +23,7 @@ public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase 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, ViewerId: owner.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); @@ -35,4 +36,28 @@ public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } + + [Fact] + public async Task Handle_PrivatePlayListOfAnotherUser_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: PlaylistVisibility.Private); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_PrivatePlayListOfViewer_ReturnsTracks() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: PlaylistVisibility.Private); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs index 9623518..51eba29 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs @@ -20,7 +20,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase 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, ViewerId: owner.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); var playList = Assert.Single(result.Value.Items); @@ -28,7 +28,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_OnlyPublic_ReturnsOnlyPublicPlayLists() + public async Task Handle_AnotherViewer_ReturnsOnlyPublicPlayLists() { var owner = TestEntities.User(); await SeedAsync( @@ -36,20 +36,35 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase 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, ViewerId: 999), TestContext.Current.CancellationToken); Assert.False(result.IsError); var playList = Assert.Single(result.Value.Items); Assert.Equal("Public one", playList.Name); } + [Fact] + public async Task Handle_Owner_SeesPrivatePlayLists() + { + var owner = TestEntities.User(); + await SeedAsync( + owner, + TestEntities.PlayList(owner.Id, "Public one", visibility: PlaylistVisibility.Public), + TestEntities.PlayList(owner.Id, "Private one", visibility: PlaylistVisibility.Private)); + + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.Items.Count); + } + [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, ViewerId: owner.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); var playList = Assert.Single(result.Value.Items); diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs deleted file mode 100644 index 3c8b364..0000000 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Musify.Application.PlayLists; -using Musify.Application.Tests.TestSupport; -using Xunit; - -namespace Musify.Application.Tests.PlayLists; - -public sealed class GetPlayListsQueryHandlerTests : HandlerTestBase -{ - private GetPlayListsQueryHandler CreateHandler() => new(Database); - - [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); - - 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); - - Assert.False(result.IsError); - Assert.Empty(result.Value.Items); - } -} diff --git a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs index e526555..ec75365 100644 --- a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs @@ -25,16 +25,27 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp"); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.Equal(intent.Id, result.Value.Id); } + [Fact] + public async Task ValidateAndLoadAsync_IntentIssuedForAnotherPurpose_ReturnsValidationError() + { + var intent = TestEntities.UploadIntent(owner.Id, UploadIntentPurpose.TrackAudio); + await SeedAsync(owner, intent); + + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + [Fact] public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound() { - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -46,7 +57,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id); await SeedAsync(owner, stranger, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -57,7 +68,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Conflict, result.FirstError.Type); } @@ -68,7 +79,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1)); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type); } @@ -83,7 +94,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -101,7 +112,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type); } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs index 76a1ea8..dda3226 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -35,6 +35,19 @@ public sealed class CreateTrackCommandHandlerTests : HandlerTestBase (TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"), TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3")); + [Fact] + public async Task Handle_SameIntentForPictureAndAudio_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, picture.Id, [Genre.Pop]); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + [Fact] public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent() { diff --git a/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs deleted file mode 100644 index 1dd4718..0000000 --- a/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using ErrorOr; -using Musify.Application.Tests.TestSupport; -using Musify.Application.Users; -using Xunit; - -namespace Musify.Application.Tests.Users; - -public sealed class CreateUserCommandHandlerTests : HandlerTestBase -{ - private CreateUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [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); - - 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); - } - - [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 result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); - } -} diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs index 08c4381..caedf74 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs @@ -216,4 +216,37 @@ public sealed class TicketValidationMiddlewareTests : IDisposable Assert.True(wasNextCalled()); Assert.Equal("bytes=1000-2000", context.Request.Headers.Range.ToString()); } + + [Theory] + [InlineData("bytes=0-10,20-30")] + [InlineData("garbage")] + [InlineData("items=0-10")] + public async Task InvokeAsync_TicketWithMaxBytes_MultiOrInvalidRange_ReplacesItWithBoundedRange(string requestedRange) + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + context.Request.Headers.Range = requestedRange; + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString()); + } + + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_SuffixRange_ReturnsRangeNotSatisfiable() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + context.Request.Headers.Range = "bytes=-500"; + + await middleware.InvokeAsync(context); + + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode); + } } diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 064a333..7fe2d25 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -279,24 +279,6 @@ export interface paths { patch?: never; trace?: never; }; - "/playlists": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Paginated PlayLists. */ - get: operations["GetPlayLists"]; - put?: never; - /** Create A New PlayList. */ - post: operations["CreatePlayList"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/playlists/{id}": { parameters: { query?: never; @@ -321,7 +303,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated PlayLists For A User. */ + /** Get Paginated PlayLists For A User. Private PlayLists Are Only Returned To Their Owner. */ get: operations["GetPlayListsByUserId"]; put?: never; post?: never; @@ -366,6 +348,23 @@ export interface paths { patch?: never; trace?: never; }; + "/playlists": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create A New PlayList. */ + post: operations["CreatePlayList"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/playlists/{playlistId}": { parameters: { query?: never; @@ -565,8 +564,7 @@ export interface paths { /** Get Paginated Users. */ get: operations["GetUsers"]; put?: never; - /** Create A New User. */ - post: operations["CreateUser"]; + post?: never; delete?: never; options?: never; head?: never; @@ -810,13 +808,6 @@ export interface components { /** @default false */ isExplicit: boolean; }; - CreateUserRequest: { - /** Format: int64 */ - id: number | string; - name: string; - firstName: null | string; - secondName: null | string; - }; /** @enum {unknown} */ Genre: "Pop" | "Rock" | "HipHop" | "RnB" | "Jazz" | "Blues" | "Classical" | "Electronic" | "House" | "Techno" | "Trance" | "Dubstep" | "DrumAndBass" | "Metal" | "Punk" | "Reggae" | "Reggaeton" | "Country" | "Folk" | "Indie" | "KPop" | "Latin" | "Soul" | "Funk" | "Ambient" | "Lofi"; GenreResponse: { @@ -914,19 +905,6 @@ export interface components { hasNextPage: boolean; hasPreviousPage: boolean; }; - PaginatedResponseOfUserApplicationResponse: { - items: components["schemas"]["UserApplicationResponse"][]; - /** Format: int32 */ - pageNumber: number | string; - /** Format: int32 */ - pageSize: number | string; - /** Format: int32 */ - pageCount: number | string; - /** Format: int32 */ - totalItemCount: number | string; - hasNextPage: boolean; - hasPreviousPage: boolean; - }; PlaybackPublicConfigResponse: { allowAnonymousListening: boolean; /** Format: int32 */ @@ -1729,76 +1707,6 @@ export interface operations { }; }; }; - GetPlayLists: { - parameters: { - query?: { - pageNumber?: number | string; - pageSize?: number | string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["PaginatedResponseOfPlayListApplicationResponse"]; - }; - }; - }; - }; - CreatePlayList: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreatePlayListRequest"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["PlayListApplicationResponse"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; GetPlayListById: { parameters: { query?: never; @@ -1834,7 +1742,6 @@ export interface operations { name?: string; pageNumber?: number | string; pageSize?: number | string; - onlyPublic?: boolean; }; header?: never; path: { @@ -1963,6 +1870,53 @@ export interface operations { }; }; }; + CreatePlayList: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreatePlayListRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PlayListApplicationResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; UpdatePlayList: { parameters: { query?: never; @@ -2493,46 +2447,6 @@ export interface operations { }; }; }; - CreateUser: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateUserRequest"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["UserApplicationResponse"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; - }; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; GetUserById: { parameters: { query?: never; @@ -2611,6 +2525,20 @@ export interface operations { "application/json": components["schemas"]["ListeningStatsResponse"]; }; }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; GetLastTrackListenedByUserId: { diff --git a/web-player/src/routes/(app)/user/[id]/+page.server.ts b/web-player/src/routes/(app)/user/[id]/+page.server.ts index 9675eaa..2760ce2 100644 --- a/web-player/src/routes/(app)/user/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/user/[id]/+page.server.ts @@ -14,7 +14,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent api.GET('/playlists/users/{userId}', { params: { path: { userId: params.id }, - query: { pageNumber: 1, pageSize: PROFILE_PLAYLISTS_PAGE_SIZE, onlyPublic: true } + query: { pageNumber: 1, pageSize: PROFILE_PLAYLISTS_PAGE_SIZE } } }) ]); From ab388e6ad6e9d9b944a57ef3578769a00ad06623 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 05:02:04 +0200 Subject: [PATCH 03/21] Fix processing pipeline: report album failures, fault handling on create slips, config key names, audio duration --- .../Albums/RequestAlbumPictureUpload.cs | 2 +- .../UploadIntentConfiguration.cs | 6 ++--- .../PlayLists/RequestPlayListPictureUpload.cs | 2 +- .../Tracks/RequestTrackUploadUrls.cs | 4 ++-- .../Jobs/TemporalUploadsCleanupJob.cs | 10 ++++---- .../Audio/TranscodeAudioActivity.cs | 2 +- .../Audio/UpdateTrackAudioActivity.cs | 6 +---- .../Consumers/ProcessingSlipFaultConsumer.cs | 22 +++++++++++++++++ .../AlbumPictureSourceRoutingSlipBuilder.cs | 17 ++++++++++--- .../Builders/CreateTrackRoutingSlipBuilder.cs | 9 +++++++ ...PlayListPictureSourceRoutingSlipBuilder.cs | 17 ++++++++++--- .../RoutingSlip/RoutingSlipVariableNames.cs | 6 +++++ .../Sagas/AlbumProcessingStateMachine.cs | 12 ++++++++-- .../Sagas/PlayListProcessingStateMachine.cs | 12 ++++++++-- .../Sagas/TrackProcessingStateMachine.cs | 24 +++++++++++++++---- 15 files changed, 119 insertions(+), 32 deletions(-) diff --git a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs index c273345..09c3244 100644 --- a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs +++ b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs @@ -45,7 +45,7 @@ public class RequestAlbumPictureUploadCommandHandler( var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; var tempKey = albumConfiguration.Routes.BuildTempPicturePath( - uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName); + uploadIntentConfiguration.TempRootPrefix, request.UserId, objectName); try { diff --git a/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs b/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs index 15463ea..eb05dee 100644 --- a/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs @@ -31,11 +31,11 @@ public class UploadIntentConfiguration public int ExpiredIntentsRetentionDays { get; set; } = 7; [Required, MinLength(1)] - public string TemporalRootPrefix { get; set; } = "temporal"; + public string TempRootPrefix { get; set; } = "temp"; [Range(1, 30)] - public int TemporalUploadsRetentionDays { get; set; } = 3; + public int TempUploadsRetentionDays { get; set; } = 3; [Range(60, 86400)] - public int TemporalCleanUpJobIntervalSeconds { get; set; } = 3600; + public int TempCleanupJobIntervalSeconds { get; set; } = 3600; } diff --git a/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs index 256ea6d..38c3a0f 100644 --- a/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs +++ b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs @@ -45,7 +45,7 @@ public class RequestPlayListPictureUploadCommandHandler( var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; var tempKey = playListConfiguration.Routes.BuildTempPicturePath( - uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName); + uploadIntentConfiguration.TempRootPrefix, request.UserId, objectName); try { diff --git a/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs index fa077db..4d2fa1d 100644 --- a/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs +++ b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs @@ -51,9 +51,9 @@ public class RequestTrackUploadUrlsCommandHandler( var audioObjectName = $"{Guid.NewGuid()}.{request.AudioFileType.TrimStart('.').ToLowerInvariant()}"; var tempPictureKey = trackConfiguration.Routes.BuildTempPicturePath( - uploadIntentConfiguration.TemporalRootPrefix, request.UserId, pictureObjectName); + uploadIntentConfiguration.TempRootPrefix, request.UserId, pictureObjectName); var tempAudioKey = trackConfiguration.Routes.BuildTempAudioPath( - uploadIntentConfiguration.TemporalRootPrefix, request.UserId, audioObjectName); + uploadIntentConfiguration.TempRootPrefix, request.UserId, audioObjectName); try { diff --git a/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs b/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs index 3f17c5c..d866c1f 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs @@ -16,10 +16,10 @@ public class TemporalUploadsCleanUpJob( protected override async Task ExecuteAsync(CancellationToken stoppingToken) { logger.LogInformation("TemporalUploadsCleanUpJob started (interval: {Interval}s, prefix: {Prefix})", - uploadIntentConfiguration.TemporalCleanUpJobIntervalSeconds, - uploadIntentConfiguration.TemporalRootPrefix); + uploadIntentConfiguration.TempCleanupJobIntervalSeconds, + uploadIntentConfiguration.TempRootPrefix); - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(uploadIntentConfiguration.TemporalCleanUpJobIntervalSeconds)); + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(uploadIntentConfiguration.TempCleanupJobIntervalSeconds)); do { @@ -40,13 +40,13 @@ public class TemporalUploadsCleanUpJob( await using var scope = scopeFactory.CreateAsyncScope(); var storageService = scope.ServiceProvider.GetRequiredService(); - var cutOff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TemporalUploadsRetentionDays); + var cutOff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TempUploadsRetentionDays); var deleted = 0; var errors = 0; await foreach (var (key, lastModified) in storageService.ListObjectsAsync( storageConfiguration.Bucket, - uploadIntentConfiguration.TemporalRootPrefix, + uploadIntentConfiguration.TempRootPrefix, cancellationToken)) { if (lastModified >= cutOff) diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs index 6cf7d56..2c674b6 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs @@ -55,7 +55,7 @@ internal class TranscodeAudioActivity( var log = new TranscodeAudioLog(workingDirectory); return executeContext.CompletedWithVariables(log, new Dictionary { - [RoutingSlipVariableNames.Audio.DurationSeconds] = (int)Math.Round(transcodeResult.Duration.TotalSeconds) + [RoutingSlipVariableNames.Audio.DurationSeconds] = transcodeResult.Duration.TotalSeconds }); } catch (Exception exception) diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs index 06b72ab..ef79453 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs @@ -38,11 +38,7 @@ internal class UpdateTrackAudioActivity( 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(); + throw new InvalidOperationException($"Audio folder key is empty for track {track.Id}"); } try diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs index e796383..8c1dca6 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs @@ -32,6 +32,28 @@ public class ProcessingSlipFaultConsumer : IConsumer case RoutingSlipVariableNames.ProcessKinds.PlayListPicture: await context.Publish(new PlayListPictureProcessingFailed(subjectId)); break; + case RoutingSlipVariableNames.ProcessKinds.AlbumPicture: + await context.Publish(new AlbumPictureProcessingFailed(subjectId)); + break; + case RoutingSlipVariableNames.ProcessKinds.TrackCreation: + await context.Publish(new TrackProcessingFailed( + subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), + Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey), + Variable(variables, RoutingSlipVariableNames.Workflow.AudioKey))); + break; + case RoutingSlipVariableNames.ProcessKinds.AlbumCreation: + await context.Publish(new AlbumProcessingFailed( + subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), + Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey))); + break; + case RoutingSlipVariableNames.ProcessKinds.PlayListCreation: + await context.Publish(new PlayListProcessingFailed( + subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), + Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey))); + break; } } + + private static string? Variable(IDictionary variables, string name) => + variables.TryGetValue(name, out var value) ? value?.ToString() : null; } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs index 73ca7bf..866dbca 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs @@ -22,7 +22,8 @@ public class AlbumPictureSourceRoutingSlipBuilder message.Small, message.Medium, message.Large, - correlationId); + correlationId, + RoutingSlipVariableNames.ProcessKinds.AlbumCreation); public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId) => BuildSlip( @@ -34,7 +35,8 @@ public class AlbumPictureSourceRoutingSlipBuilder message.Small, message.Medium, message.Large, - correlationId); + correlationId, + RoutingSlipVariableNames.ProcessKinds.AlbumPicture); private static RoutingSlipBuilder BuildSlip( Guid albumId, @@ -45,7 +47,8 @@ public class AlbumPictureSourceRoutingSlipBuilder ImageSize small, ImageSize medium, ImageSize large, - Guid? correlationId) + Guid? correlationId, + string processKind) { var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); @@ -79,6 +82,14 @@ public class AlbumPictureSourceRoutingSlipBuilder medium, large)); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, albumId); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, bucket); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, pictureDestinationKey); + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), + RoutingSlipEvents.Faulted); + 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 e2aa438..426f544 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs @@ -57,6 +57,15 @@ public class CreateTrackRoutingSlipBuilder message.Medium, message.Large)); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackCreation); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, message.Bucket); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.AudioKey, message.AudioDestinationKey); + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), + RoutingSlipEvents.Faulted); + return routingSlipBuilder; } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs index 27bdaa5..6e79a3b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs @@ -22,7 +22,8 @@ public class PlayListPictureSourceRoutingSlipBuilder message.Small, message.Medium, message.Large, - correlationId); + correlationId, + RoutingSlipVariableNames.ProcessKinds.PlayListCreation); public RoutingSlipBuilder Build(UpdatePlayListPictureSourceEvent message, Guid? correlationId) => BuildSlip( @@ -34,7 +35,8 @@ public class PlayListPictureSourceRoutingSlipBuilder message.Small, message.Medium, message.Large, - correlationId); + correlationId, + RoutingSlipVariableNames.ProcessKinds.PlayListPicture); private static RoutingSlipBuilder BuildSlip( Guid playListId, @@ -45,7 +47,8 @@ public class PlayListPictureSourceRoutingSlipBuilder ImageSize small, ImageSize medium, ImageSize large, - Guid? correlationId) + Guid? correlationId, + string processKind) { var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); @@ -79,6 +82,14 @@ public class PlayListPictureSourceRoutingSlipBuilder medium, large)); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, playListId); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, bucket); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, pictureDestinationKey); + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), + RoutingSlipEvents.Faulted); + return routingSlipBuilder; } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs index 71fd347..6d49e2e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs @@ -8,6 +8,9 @@ internal static class RoutingSlipVariableNames internal const string CorrelationId = "Workflow.CorrelationId"; internal const string SubjectId = "Workflow.SubjectId"; internal const string ProcessKind = "Workflow.ProcessKind"; + internal const string Bucket = "Workflow.Bucket"; + internal const string PictureKey = "Workflow.PictureKey"; + internal const string AudioKey = "Workflow.AudioKey"; } internal static class ProcessKinds @@ -16,6 +19,9 @@ internal static class RoutingSlipVariableNames internal const string TrackAudio = "TrackAudio"; internal const string PlayListPicture = "PlayListPicture"; internal const string AlbumPicture = "AlbumPicture"; + internal const string TrackCreation = "TrackCreation"; + internal const string AlbumCreation = "AlbumCreation"; + internal const string PlayListCreation = "PlayListCreation"; } internal static class Audio diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs index 4275aa4..3eaa300 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs @@ -17,8 +17,16 @@ public sealed class AlbumProcessingStateMachine : MassTransitStateMachine state.CurrentState); 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)); + Event(() => PictureProcessed, config => + { + config.CorrelateById(context => context.Message.AlbumId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => PictureFailed, config => + { + config.CorrelateById(context => context.Message.AlbumId); + config.OnMissingInstance(instance => instance.Discard()); + }); Initially( When(ProcessingStarted) diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs index c9eaec4..3ab7e6c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs @@ -17,8 +17,16 @@ public sealed class PlayListProcessingStateMachine : MassTransitStateMachine state.CurrentState); 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)); + Event(() => PictureProcessed, config => + { + config.CorrelateById(context => context.Message.PlayListId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => PictureFailed, config => + { + config.CorrelateById(context => context.Message.PlayListId); + config.OnMissingInstance(instance => instance.Discard()); + }); Initially( When(ProcessingStarted) diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs index c01565e..5fdc6de 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs @@ -19,10 +19,26 @@ public sealed class TrackProcessingStateMachine : MassTransitStateMachine state.CurrentState); 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)); + Event(() => PictureProcessed, config => + { + config.CorrelateById(context => context.Message.TrackId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => AudioProcessed, config => + { + config.CorrelateById(context => context.Message.TrackId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => PictureFailed, config => + { + config.CorrelateById(context => context.Message.TrackId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => AudioFailed, config => + { + config.CorrelateById(context => context.Message.TrackId); + config.OnMissingInstance(instance => instance.Discard()); + }); Initially( When(ProcessingStarted) From 0e3576922fab82f5b5a6b724b92b1a368b7b90c4 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 05:14:05 +0200 Subject: [PATCH 04/21] Fix query and API bugs: 403 for non-owners, page bounds, lifecycle filters, cover fallback, race-safe inserts, async album delete --- .../Albums/AddTrackToAlbum.cs | 45 +- .../Musify.Application/Albums/CreateAlbum.cs | 8 +- .../Musify.Application/Albums/DeleteAlbum.cs | 33 +- .../Musify.Application/Albums/GetAlbumById.cs | 3 +- .../Albums/GetAlbumCover.cs | 22 +- .../Albums/GetAlbumTracks.cs | 5 +- .../Musify.Application/Albums/GetAlbums.cs | 3 +- .../Albums/GetAlbumsByUserId.cs | 3 +- .../Albums/GetRecentlyListenedAlbums.cs | 3 +- .../Albums/RemoveTrackFromAlbum.cs | 11 +- .../Musify.Application/Albums/UpdateAlbum.cs | 10 +- .../Musify.Application/Contracts/IDatabase.cs | 3 + .../Contracts/IStorageService.cs | 2 +- .../Events/DeleteAlbumEvent.cs | 5 + .../Musify.Application/Genres/GetGenres.cs | 2 +- .../Likes/GetLikedTracks.cs | 3 +- .../Likes/ToggleTrackLike.cs | 7 +- .../Mixes/GetMixesByUserId.cs | 22 +- .../Musify.Application/Mixes/MixItemMapper.cs | 3 +- .../PlayLists/AddTrackToPlayList.cs | 41 +- .../PlayLists/CreatePlayList.cs | 10 +- .../PlayLists/DeletePlayList.cs | 3 +- .../PlayLists/GetPlayListById.cs | 2 +- .../PlayLists/GetPlayListCover.cs | 22 +- .../PlayLists/GetPlayListTracks.cs | 3 +- .../PlayLists/GetPlayListsByUserId.cs | 3 +- .../PlayLists/RemoveTrackFromPlayList.cs | 32 +- .../PlayLists/UpdatePlayList.cs | 16 +- .../Musify.Application/Shared/AppErrors.cs | 15 + .../Musify.Application/Shared/Pagination.cs | 6 + .../Musify.Application/Tracks/DeleteTrack.cs | 3 +- .../Tracks/GetLastTrackListenedByUserId.cs | 17 +- .../Tracks/GetListeningHistory.cs | 4 +- .../Musify.Application/Tracks/GetTrackById.cs | 3 +- .../Tracks/GetTrackStream.cs | 3 +- .../Musify.Application/Tracks/GetTracks.cs | 2 +- .../Tracks/GetTracksByUserId.cs | 8 +- .../Musify.Application/Users/FollowUser.cs | 7 +- .../Users/GetListeningStats.cs | 3 + .../ValueObjects/AlbumPictures.cs | 11 +- .../ValueObjects/PlayListPictures.cs | 11 +- .../Albums/DeleteAlbumFromDbActivity.cs | 35 + .../Albums/MarkAlbumAsRemovingActivity.cs | 36 + .../Files/DownloadFileFromBucketActivity.cs | 3 +- .../Arguments/DeleteAlbumFromDbArguments.cs | 4 + .../Arguments/MarkAlbumAsRemovingArguments.cs | 4 + .../Consumers/DeleteAlbumConsumer.cs | 24 + ...TransitDependencyInjection.Registration.cs | 4 + .../MassTransit/Logs/UpdateAlbumPictureLog.cs | 6 +- .../Logs/UpdatePlayListPictureLog.cs | 6 +- .../MassTransit/RoutingSlip/ActivityNames.cs | 5 + .../Builders/DeleteAlbumRoutingSlipBuilder.cs | 93 ++ .../Persistence/Database.cs | 25 +- ...0924030938_PlayListTrackUnique.Designer.cs | 1174 +++++++++++++++++ .../20260924030938_PlayListTrackUnique.cs | 46 + .../Migrations/DatabaseModelSnapshot.cs | 8 +- .../Services/StorageService.cs | 7 +- .../Musify.Api/Endpoints/AlbumEndpoints.cs | 36 +- .../Musify.Api/Endpoints/LikeEndpoints.cs | 8 +- .../Musify.Api/Endpoints/PlayListEndpoints.cs | 20 +- .../Musify.Api/Endpoints/TrackEndpoints.cs | 17 +- .../Musify.Api/Endpoints/UserEndpoints.cs | 26 +- .../Extensions/ErrorOrHttpExtensions.cs | 6 +- backend/Hosts/Musify.Api/Models/PageQuery.cs | 9 + .../Validators/PageQueryValidator.cs | 17 + .../CreatePlayListRequestValidator.cs | 3 + .../UpdatePlayListRequestValidator.cs | 4 + .../Endpoints/AlbumEndpointsTests.cs | 4 +- .../Endpoints/UserEndpointsTests.cs | 40 +- .../AddTrackToAlbumCommandHandlerTests.cs | 8 +- .../Albums/DeleteAlbumCommandHandlerTests.cs | 18 +- .../Albums/GetAlbumCoverQueryHandlerTests.cs | 15 + ...RemoveTrackFromAlbumCommandHandlerTests.cs | 4 +- .../Albums/UpdateAlbumCommandHandlerTests.cs | 4 +- .../AddTrackToPlayListCommandHandlerTests.cs | 18 +- .../DeletePlayListCommandHandlerTests.cs | 4 +- ...oveTrackFromPlayListCommandHandlerTests.cs | 26 +- .../UpdatePlayListCommandHandlerTests.cs | 10 +- .../TestSupport/TestDatabase.cs | 18 + .../Tracks/DeleteTrackCommandHandlerTests.cs | 4 +- ...tTrackListenedByUserIdQueryHandlerTests.cs | 9 +- .../GetTracksByUserIdQueryHandlerTests.cs | 15 - .../GetListeningStatsQueryHandlerTests.cs | 16 +- .../Services/StorageServiceTests.cs | 9 + web-player/src/lib/api/schema.d.ts | 67 +- web-player/src/lib/config.ts | 5 +- web-player/src/lib/server/pagination.ts | 22 + .../routes/(app)/playlists/+page.server.ts | 16 +- .../(app)/playlists/[id]/+page.server.ts | 17 +- web-player/src/routes/+layout.server.ts | 12 +- 90 files changed, 2083 insertions(+), 322 deletions(-) create mode 100644 backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs create mode 100644 backend/Core/Musify.Application/Shared/AppErrors.cs create mode 100644 backend/Core/Musify.Application/Shared/Pagination.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/DeleteAlbumFromDbActivity.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsRemovingActivity.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteAlbumFromDbArguments.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsRemovingArguments.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteAlbumRoutingSlipBuilder.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.Designer.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.cs create mode 100644 backend/Hosts/Musify.Api/Models/PageQuery.cs create mode 100644 backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs create mode 100644 web-player/src/lib/server/pagination.ts diff --git a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs index a8c783a..633dbb3 100644 --- a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs +++ b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Domain.Entities; namespace Musify.Application.Albums; @@ -21,48 +22,30 @@ public class AddTrackToAlbumCommandHandler( .AsNoTracking() .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); if (album == null) - { - logger.LogInformation("Album {AlbumId} not found", request.AlbumId); - return Error.NotFound(); - } + return AppErrors.NotFound("Album", request.AlbumId); if (album.OwnerUserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId); - return Error.Unauthorized(); - } + return AppErrors.Forbidden("Album", request.AlbumId); 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"); - } + return AppErrors.NotFound("Track", request.TrackId); if (track.OwnerUserId != request.UserId) - { - logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId); - return Error.Unauthorized(); - } + return AppErrors.Forbidden("Track", request.TrackId); 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."); - } + return AlreadyInAlbum; - 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; + // Album track numbers are user-visible, so they start at 1. + var nextTrackNumber = (await database.AlbumHasTracks + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) + .MaxAsync(albumTrack => (int?)albumTrack.TrackNumber, cancellationToken) ?? 0) + 1; await database.AlbumHasTracks.AddAsync(new AlbumHasTrack { @@ -71,11 +54,15 @@ public class AddTrackToAlbumCommandHandler( TrackNumber = nextTrackNumber }, cancellationToken); - await database.SaveChangesAsync(cancellationToken); + var saved = await database.TrySaveChangesAsync(AlreadyInAlbum, cancellationToken); + if (saved.IsError) + return saved; logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}", request.TrackId, request.AlbumId, nextTrackNumber); - return new Success(); + return Result.Success; } + + private static Error AlreadyInAlbum => AppErrors.Conflict("Album.TrackAlreadyAdded", "Track is already in the album."); } diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index 4e124a1..39722ca 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -57,13 +57,7 @@ public class CreateAlbumCommandHandler( NormalizedTitle = title.ToUpperInvariant(), Description = request.Description, ReleaseYear = request.ReleaseYear, - Pictures = new AlbumPictures - { - OriginalName = intent.ObjectName, - SmallName = intent.ObjectName, - MediumName = intent.ObjectName, - LargeName = intent.ObjectName - } + Pictures = AlbumPictures.Pending(intent.ObjectName) }; await database.Albums.AddAsync(album, cancellationToken); diff --git a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs index 11b1bf8..27787e9 100644 --- a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs +++ b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs @@ -3,6 +3,9 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -11,39 +14,27 @@ public record DeleteAlbumCommand(long UserId, Guid AlbumId) public class DeleteAlbumCommandHandler( IDatabase database, + IEventBus eventBus, ILogger logger) : ICommandHandler> { public async ValueTask> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) { var album = await database.Albums - .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); + .SingleOrDefaultAsync(a => a.Id == request.AlbumId && a.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); if (album == null) - { - logger.LogDebug("Album {AlbumId} not found", request.AlbumId); - return Error.NotFound(); - } + return AppErrors.NotFound("Album", request.AlbumId); if (album.OwnerUserId != request.UserId) - { - logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId); - return Error.Unauthorized(); - } + return AppErrors.Forbidden("Album", request.AlbumId); - database.Albums.Remove(album); + album.LifeCycleStatus = LifeCycleStatus.Removing; - 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}"); - } + await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken); + await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId); + logger.LogInformation("Marked album {AlbumId} of user {UserId} as removing", request.AlbumId, request.UserId); - return new Success(); + return Result.Success; } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumById.cs b/backend/Core/Musify.Application/Albums/GetAlbumById.cs index 03991cb..8a72541 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumById.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumById.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -16,7 +17,7 @@ public class GetAlbumByIdQueryHandler(IDatabase database) { var album = await database.Albums .AsNoTracking() - .Where(a => a.Id == request.Id) + .Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active) .Select(a => new { Album = a, diff --git a/backend/Core/Musify.Application/Albums/GetAlbumCover.cs b/backend/Core/Musify.Application/Albums/GetAlbumCover.cs index 333f206..e4c0ddf 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumCover.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumCover.cs @@ -4,6 +4,8 @@ using Microsoft.EntityFrameworkCore; using MimeMapping; using Musify.Application.Configuration; using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -20,14 +22,15 @@ public class GetAlbumCoverQueryHandler( { public async ValueTask> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken) { - var pictures = await database.Albums.AsNoTracking() - .Where(a => a.Id == request.AlbumId) - .Select(a => a.Pictures) + var owned = await database.Albums.AsNoTracking() + .Where(e => e.Id == request.AlbumId && e.LifeCycleStatus == LifeCycleStatus.Active) + .Select(e => new { OwnerUserId = e.OwnerUserId, e.Pictures }) .SingleOrDefaultAsync(cancellationToken); - if (pictures == null) - return Error.NotFound(); + if (owned?.Pictures == null) + return AppErrors.NotFound("AlbumCover", request.AlbumId); + var pictures = owned.Pictures; var routes = albumConfiguration.Routes; var (name, key) = request.Size.ToLowerInvariant() switch { @@ -36,8 +39,15 @@ public class GetAlbumCoverQueryHandler( _ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName)) }; + // The resized files only exist once processing has finished; until then serve the original. if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) - return Error.NotFound(); + { + name = pictures.OriginalName; + key = string.IsNullOrEmpty(name) ? null : routes.BuildOriginalPicturePath(owned.OwnerUserId, name); + } + + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) + return AppErrors.NotFound("AlbumCover", request.AlbumId); var contentType = MimeUtility.GetMimeMapping(name); return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType); diff --git a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs index 1b5ed0d..9138cc7 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs @@ -6,6 +6,7 @@ using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -22,7 +23,7 @@ public class GetAlbumTracksQueryHandler(IDatabase database) { var albumExists = await database.Albums .AsNoTracking() - .AnyAsync(album => album.Id == request.AlbumId, cancellationToken); + .AnyAsync(album => album.Id == request.AlbumId && album.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); if (!albumExists) return Error.NotFound(); @@ -30,7 +31,7 @@ public class GetAlbumTracksQueryHandler(IDatabase database) .AsNoTracking() .Include(albumTrack => albumTrack.Track.Owner) .Include(albumTrack => albumTrack.Track.Tags) - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId); + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active); var totalCount = await tracksQuery.CountAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Albums/GetAlbums.cs b/backend/Core/Musify.Application/Albums/GetAlbums.cs index b8d64e9..7bec513 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbums.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using X.PagedList.EF; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -17,7 +18,7 @@ public class GetAlbumsQueryHandler(IDatabase database) { var albumsQuery = database.Albums .AsNoTracking() - .AsQueryable(); + .Where(album => album.LifeCycleStatus == LifeCycleStatus.Active); if (!string.IsNullOrEmpty(request.Title)) { diff --git a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs index 9f8c829..f9f18ac 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs @@ -6,6 +6,7 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using X.PagedList; using X.PagedList.EF; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -25,7 +26,7 @@ public class GetAlbumsByUserIdQueryHandler(IDatabase database) var albumsQuery = database.Albums .AsNoTracking() - .Where(album => album.OwnerUserId == request.UserId); + .Where(album => album.OwnerUserId == request.UserId && album.LifeCycleStatus == LifeCycleStatus.Active); var totalCount = await albumsQuery.CountAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs index 4eeb1e4..bcee2e1 100644 --- a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -38,7 +39,7 @@ public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database) var albums = await database.Albums .AsNoTracking() - .Where(album => albumIds.Contains(album.Id)) + .Where(album => albumIds.Contains(album.Id) && album.LifeCycleStatus == LifeCycleStatus.Active) .Select(album => new { Album = album, diff --git a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs index a626a88..b84c21c 100644 --- a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs +++ b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; namespace Musify.Application.Albums; @@ -20,16 +21,10 @@ public class RemoveTrackFromAlbumCommandHandler( .AsNoTracking() .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); if (album == null) - { - logger.LogInformation("Album {AlbumId} not found", request.AlbumId); - return Error.NotFound(); - } + return AppErrors.NotFound("Album", request.AlbumId); if (album.OwnerUserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId); - return Error.Unauthorized(); - } + return AppErrors.Forbidden("Album", request.AlbumId); var albumTracks = await database.AlbumHasTracks .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index d2984a5..3c23fea 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -45,7 +45,7 @@ public class UpdateAlbumCommandHandler( if (album.OwnerUserId != request.UserId) { logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId); - return Error.Unauthorized(); + return AppErrors.Forbidden("Album", request.AlbumId); } var title = request.Title.Trim(); @@ -67,13 +67,7 @@ public class UpdateAlbumCommandHandler( return validation.Errors; pictureIntent = validation.Value; - album.Pictures = new AlbumPictures - { - OriginalName = pictureIntent.ObjectName, - SmallName = pictureIntent.ObjectName, - MediumName = pictureIntent.ObjectName, - LargeName = pictureIntent.ObjectName - }; + album.Pictures = AlbumPictures.Pending(pictureIntent.ObjectName); finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); } diff --git a/backend/Core/Musify.Application/Contracts/IDatabase.cs b/backend/Core/Musify.Application/Contracts/IDatabase.cs index ea3c605..de3b8e2 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabase.cs @@ -1,3 +1,4 @@ +using ErrorOr; using Microsoft.EntityFrameworkCore; using Musify.Domain.Entities; @@ -38,4 +39,6 @@ public interface IDatabase public Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); public Task SaveChangesAsync(CancellationToken cancellationToken); + + public Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken); } diff --git a/backend/Core/Musify.Application/Contracts/IStorageService.cs b/backend/Core/Musify.Application/Contracts/IStorageService.cs index 4b419be..ea79bdb 100644 --- a/backend/Core/Musify.Application/Contracts/IStorageService.cs +++ b/backend/Core/Musify.Application/Contracts/IStorageService.cs @@ -2,7 +2,7 @@ namespace Musify.Application.Contracts; public interface IStorageService { - public Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); + public Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); public Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); diff --git a/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs b/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs new file mode 100644 index 0000000..ab5cfd5 --- /dev/null +++ b/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs @@ -0,0 +1,5 @@ +namespace Musify.Application.Events; + +public record DeleteAlbumEvent( + Guid AlbumId, + long UserId); diff --git a/backend/Core/Musify.Application/Genres/GetGenres.cs b/backend/Core/Musify.Application/Genres/GetGenres.cs index 82fbf97..f85cbba 100644 --- a/backend/Core/Musify.Application/Genres/GetGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetGenres.cs @@ -16,7 +16,7 @@ public class GetGenresQueryHandler(IDatabase database) { var rows = await database.TrackTags .AsNoTracking() - .Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId)) + .Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)) .GroupBy(tag => tag.Tag) .Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() }) .ToListAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs index d36f67c..d0468ad 100644 --- a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs +++ b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs @@ -6,6 +6,7 @@ using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; +using Musify.Domain.ValueObjects; namespace Musify.Application.Likes; @@ -24,7 +25,7 @@ public class GetLikedTracksQueryHandler(IDatabase database) .AsNoTracking() .Include(like => like.Track.Owner) .Include(like => like.Track.Tags) - .Where(like => like.UserId == request.UserId); + .Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active); var totalCount = await likesQuery.CountAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs index d87d641..468a655 100644 --- a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs +++ b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Domain.Entities; namespace Musify.Application.Likes; @@ -45,7 +46,11 @@ public class ToggleTrackLikeCommandHandler( nowLiked = true; } - await database.SaveChangesAsync(cancellationToken); + // A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op. + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken); + if (saved.IsError) + return true; logger.LogInformation("User {UserId} {Action} track {TrackId}", request.UserId, nowLiked ? "liked" : "unliked", request.TrackId); diff --git a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs index d988bb0..c40290d 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs @@ -21,14 +21,28 @@ public class GetMixesByUserIdQueryHandler(IDatabase database) .OrderBy(mix => mix.Position) .ToListAsync(cancellationToken); + var orderedItemsByMix = mixes.ToDictionary( + mix => mix.Id, + mix => mix.Items.OrderBy(item => item.Position).ToList()); + + var coverItems = orderedItemsByMix.Values + .SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount)) + .ToList(); + var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken)) + .GroupBy(response => response.TrackId) + .ToDictionary(group => group.Key, group => group.First()); + var responses = new List(mixes.Count); foreach (var mix in mixes) { - var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); - var coverItems = await MixItemMapper.ToResponsesAsync( - database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken); + var orderedItems = orderedItemsByMix[mix.Id]; + var covers = orderedItems + .Take(MixApplicationResponse.CoverItemCount) + .Where(item => coverResponses.ContainsKey(item.TrackId)) + .Select(item => coverResponses[item.TrackId]) + .ToList(); - responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems)); + responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, covers)); } return responses; diff --git a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs index eba4dd3..e4928dc 100644 --- a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs +++ b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Mixes.Responses; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; namespace Musify.Application.Mixes; @@ -18,7 +19,7 @@ internal static class MixItemMapper var tracksById = await database.Tracks .AsNoTracking() .Include(track => track.Owner) - .Where(track => trackIds.Contains(track.Id)) + .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) .Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) }) .ToDictionaryAsync(x => x.Track.Id, cancellationToken); diff --git a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs index 1c1c641..2c3f28f 100644 --- a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Domain.Entities; namespace Musify.Application.PlayLists; @@ -18,44 +19,30 @@ public class AddTrackToPlayListCommandHandler( public async ValueTask> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken) { var playList = await database.PlayLists + .AsNoTracking() .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); if (playList == null) - { - logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); - return Error.NotFound(); - } + return AppErrors.NotFound("PlayList", request.PlayListId); if (playList.UserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return Error.Unauthorized(); - } + return AppErrors.Forbidden("PlayList", request.PlayListId); 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"); - } + return AppErrors.NotFound("Track", request.TrackId); 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."); - } + return AlreadyInPlayList; - 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; + // Playlist positions are internal indexes, so they start at 0. + var nextPosition = (await database.PlayListHasTracks + .Where(plt => plt.PlayListId == request.PlayListId) + .MaxAsync(plt => (int?)plt.Position, cancellationToken) ?? -1) + 1; await database.PlayListHasTracks.AddAsync(new PlayListHasTrack { @@ -64,11 +51,15 @@ public class AddTrackToPlayListCommandHandler( Position = nextPosition }, cancellationToken); - await database.SaveChangesAsync(cancellationToken); + var saved = await database.TrySaveChangesAsync(AlreadyInPlayList, cancellationToken); + if (saved.IsError) + return saved; logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}", request.TrackId, request.PlayListId, nextPosition); - return new Success(); + return Result.Success; } + + private static Error AlreadyInPlayList => AppErrors.Conflict("PlayList.TrackAlreadyAdded", "Track is already in the playlist."); } diff --git a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index c2bb640..ba015de 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -53,7 +53,7 @@ public class CreatePlayListCommandHandler( var playList = new PlayList { UserId = request.UserId, - Name = request.Name, + Name = request.Name.Trim(), NormalizedName = request.Name.Trim().ToUpperInvariant(), Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), Pictures = resolvedPictures.Pictures, @@ -97,13 +97,7 @@ public class CreatePlayListCommandHandler( 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); + return new ResolvedPlayListPictures(PlayListPictures.Pending(validation.Value.ObjectName), validation.Value); } private async Task> PublishCreatePlayListEventAsync( diff --git a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs index 066649a..710a5f4 100644 --- a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Application.Events; using Musify.Domain.ValueObjects; @@ -29,7 +30,7 @@ public class DeletePlayListCommandHandler( if (playList.UserId != request.UserId) { logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId); - return Error.Unauthorized(); + return AppErrors.Forbidden("PlayList", request.PlayListId); } playList.LifeCycleStatus = LifeCycleStatus.Removing; diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs index 506918d..869f0e1 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs @@ -17,7 +17,7 @@ public class GetPlayListByIdQueryHandler(IDatabase database) { var entry = await database.PlayLists .AsNoTracking() - .Where(p => p.Id == request.Id) + .Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active) .Select(p => new { PlayList = p, diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs index 3e739ae..7660459 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs @@ -4,6 +4,8 @@ using Microsoft.EntityFrameworkCore; using MimeMapping; using Musify.Application.Configuration; using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; namespace Musify.Application.PlayLists; @@ -20,14 +22,15 @@ public class GetPlayListCoverQueryHandler( { public async ValueTask> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken) { - var pictures = await database.PlayLists.AsNoTracking() - .Where(p => p.Id == request.PlayListId) - .Select(p => p.Pictures) + var owned = await database.PlayLists.AsNoTracking() + .Where(e => e.Id == request.PlayListId && e.LifeCycleStatus == LifeCycleStatus.Active) + .Select(e => new { OwnerUserId = e.UserId, e.Pictures }) .SingleOrDefaultAsync(cancellationToken); - if (pictures == null) - return Error.NotFound(); + if (owned?.Pictures == null) + return AppErrors.NotFound("PlayListCover", request.PlayListId); + var pictures = owned.Pictures; var routes = playListConfiguration.Routes; var (name, key) = request.Size.ToLowerInvariant() switch { @@ -36,8 +39,15 @@ public class GetPlayListCoverQueryHandler( _ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName)) }; + // The resized files only exist once processing has finished; until then serve the original. if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) - return Error.NotFound(); + { + name = pictures.OriginalName; + key = string.IsNullOrEmpty(name) ? null : routes.BuildOriginalPicturePath(owned.OwnerUserId, name); + } + + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) + return AppErrors.NotFound("PlayListCover", request.PlayListId); var contentType = MimeUtility.GetMimeMapping(name); return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType); diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index 6a299b8..f25cabc 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -26,6 +26,7 @@ public class GetPlayListTracksQueryHandler(IDatabase database) .AsNoTracking() .AnyAsync( p => p.Id == request.PlayListId + && p.LifeCycleStatus == LifeCycleStatus.Active && (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId), cancellationToken); if (!playListExists) @@ -35,7 +36,7 @@ public class GetPlayListTracksQueryHandler(IDatabase database) .AsNoTracking() .Include(plt => plt.Track.Owner) .Include(plt => plt.Track.Tags) - .Where(plt => plt.PlayListId == request.PlayListId); + .Where(plt => plt.PlayListId == request.PlayListId && plt.Track.LifeCycleStatus == LifeCycleStatus.Active); var totalCount = await tracksQuery.CountAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs index 0716ce7..e292836 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -26,6 +26,7 @@ public class GetPlayListsByUserIdQueryHandler(IDatabase database) var playListsQuery = database.PlayLists .AsNoTracking() .Where(p => p.UserId == request.UserId + && p.LifeCycleStatus == LifeCycleStatus.Active && (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId)); if (!string.IsNullOrEmpty(request.Name)) @@ -38,7 +39,7 @@ public class GetPlayListsByUserIdQueryHandler(IDatabase database) var pagedEntities = await playListsQuery .OrderBy(p => p.CreatedAt) - .Where(p => p.LifeCycleStatus == LifeCycleStatus.Active) + .ThenBy(p => p.Id) .Select(p => new { PlayList = p, diff --git a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs index 6a9ac11..da372fe 100644 --- a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; namespace Musify.Application.PlayLists; @@ -17,32 +18,33 @@ public class RemoveTrackFromPlayListCommandHandler( public async ValueTask> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken) { var playList = await database.PlayLists + .AsNoTracking() .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); if (playList == null) - { - logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); - return Error.NotFound(); - } + return AppErrors.NotFound("PlayList", request.PlayListId); if (playList.UserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return Error.Unauthorized(); - } + return AppErrors.Forbidden("PlayList", request.PlayListId); - var link = await database.PlayListHasTracks - .SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken); + var playListTracks = await database.PlayListHasTracks + .Where(plt => plt.PlayListId == request.PlayListId) + .OrderBy(plt => plt.Position) + .ToListAsync(cancellationToken); + + var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId); if (link == null) - { - logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId); - return Error.NotFound(description: "Track is not in the playlist."); - } + return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist."); database.PlayListHasTracks.Remove(link); + + var position = 0; + foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id)) + remaining.Position = position++; + await database.SaveChangesAsync(cancellationToken); logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId); - return new Success(); + return Result.Success; } } diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index a791752..d497e12 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -44,18 +44,18 @@ public class UpdatePlayListCommandHandler( if (playListEntity.UserId != request.UserId) { logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return Error.Unauthorized(); + return AppErrors.Forbidden("PlayList", request.PlayListId); } if (!string.IsNullOrWhiteSpace(request.NewName)) { - playListEntity.Name = request.NewName; + playListEntity.Name = request.NewName.Trim(); playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant(); } - if (!string.IsNullOrWhiteSpace(request.NewDescription)) + if (request.NewDescription != null) { - playListEntity.Description = request.NewDescription; + playListEntity.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim(); } if (request.NewVisibility.HasValue) @@ -75,13 +75,7 @@ public class UpdatePlayListCommandHandler( return validation.Errors; pictureIntent = validation.Value; - playListEntity.Pictures = new PlayListPictures - { - OriginalName = pictureIntent.ObjectName, - SmallName = pictureIntent.ObjectName, - MediumName = pictureIntent.ObjectName, - LargeName = pictureIntent.ObjectName - }; + playListEntity.Pictures = PlayListPictures.Pending(pictureIntent.ObjectName); finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); } diff --git a/backend/Core/Musify.Application/Shared/AppErrors.cs b/backend/Core/Musify.Application/Shared/AppErrors.cs new file mode 100644 index 0000000..326d420 --- /dev/null +++ b/backend/Core/Musify.Application/Shared/AppErrors.cs @@ -0,0 +1,15 @@ +using ErrorOr; + +namespace Musify.Application.Shared; + +public static class AppErrors +{ + public static Error NotFound(string entity, object id) => + Error.NotFound($"{entity}.NotFound", $"{entity} {id} was not found."); + + public static Error Forbidden(string entity, object id) => + Error.Forbidden($"{entity}.Forbidden", $"You do not have permission to modify {entity} {id}."); + + public static Error Conflict(string code, string description) => + Error.Conflict(code, description); +} diff --git a/backend/Core/Musify.Application/Shared/Pagination.cs b/backend/Core/Musify.Application/Shared/Pagination.cs new file mode 100644 index 0000000..b32e156 --- /dev/null +++ b/backend/Core/Musify.Application/Shared/Pagination.cs @@ -0,0 +1,6 @@ +namespace Musify.Application.Shared; + +public record PageRequest(int PageNumber = 1, int PageSize = 20) +{ + public const int MaxPageSize = 100; +} diff --git a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs index a4b0982..e4d75bb 100644 --- a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs +++ b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Application.Events; using Musify.Domain.ValueObjects; @@ -31,7 +32,7 @@ public class DeleteTrackCommandHandler( if (track.OwnerUserId != request.UserId) { logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId); - return Error.Unauthorized(); + return AppErrors.Forbidden("Track", request.TrackId); } if (track.Audio.IsInProgress) diff --git a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs index 030fe53..4048ba3 100644 --- a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs @@ -1,29 +1,26 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; -public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery>; +public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery; public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database) - : IQueryHandler> + : IQueryHandler { - public async ValueTask> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken) + public async ValueTask Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken) { - var track = await database.ListeningHistories + return await database.ListeningHistories .AsNoTracking() .Include(t => t.Track.Owner) .Include(t => t.Track.Tags) - .Where(t => t.UserId == query.UserId) + .Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active) .OrderByDescending(t => t.ListenedAt) + .ThenBy(t => t.Id) .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted))) .FirstOrDefaultAsync(cancellationToken); - - return track == null - ? Error.NotFound("Track.NotFound", "No track found for the given user ID.") - : track; } } diff --git a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs index d61825b..e73095f 100644 --- a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs +++ b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs @@ -2,6 +2,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; @@ -28,11 +29,12 @@ public class GetListeningHistoryQueryHandler( .AsNoTracking() .Include(t => t.Owner) .Include(t => t.Tags) - .Where(t => recentTrackIds.Contains(t.Id)) + .Where(t => recentTrackIds.Contains(t.Id) && t.LifeCycleStatus == LifeCycleStatus.Active) .Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) }) .ToDictionaryAsync(t => t.Id, cancellationToken); return recentTrackIds + .Where(id => tracksById.ContainsKey(id)) .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 9c73316..7f4a1a1 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackById.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackById.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; @@ -17,7 +18,7 @@ public class GetTrackByIdQueryHandler(IDatabase database) var entity = await database.Tracks.AsNoTracking() .Include(t => t.Owner) .Include(t => t.Tags) - .Where(t => t.Id == request.TrackId) + .Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active) .Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) }) .SingleOrDefaultAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs b/backend/Core/Musify.Application/Tracks/GetTrackStream.cs index a9ceae8..2204afe 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackStream.cs @@ -6,6 +6,7 @@ using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Services; using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; @@ -26,7 +27,7 @@ public class GetTrackStreamQueryHandler( 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) + .Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active) .Select(t => new { t.Id, t.Audio }) .SingleOrDefaultAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index 8d0c533..6fc1d44 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -20,7 +20,7 @@ public class GetTracksQueryHandler(IDatabase database) .AsNoTracking() .Include(ut => ut.Track.Owner) .Include(ut => ut.Track.Tags) - .AsQueryable(); + .Where(ut => ut.Track.LifeCycleStatus == LifeCycleStatus.Active); if (!string.IsNullOrEmpty(request.Name)) { diff --git a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index edeacf2..e03ecc8 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -6,6 +6,7 @@ using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; @@ -21,14 +22,11 @@ public class GetTracksByUserIdQueryHandler(IDatabase database) { 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); + .Where(p => p.UserId == request.UserId && p.Track.LifeCycleStatus == LifeCycleStatus.Active); if (!string.IsNullOrEmpty(request.Name)) { @@ -41,7 +39,7 @@ public class GetTracksByUserIdQueryHandler(IDatabase database) 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); + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); var pagedTracks = new StaticPagedList( pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), diff --git a/backend/Core/Musify.Application/Users/FollowUser.cs b/backend/Core/Musify.Application/Users/FollowUser.cs index 767d8f3..345fc89 100644 --- a/backend/Core/Musify.Application/Users/FollowUser.cs +++ b/backend/Core/Musify.Application/Users/FollowUser.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Domain.Entities; namespace Musify.Application.Users; @@ -40,7 +41,11 @@ public class FollowUserCommandHandler( FollowedId = request.FollowedId }, cancellationToken); - await database.SaveChangesAsync(cancellationToken); + // A concurrent double click can hit the unique index; already following is a successful no-op. + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("UserFollow.Duplicate", "Already following this user."), cancellationToken); + if (saved.IsError) + return Result.Success; logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId); diff --git a/backend/Core/Musify.Application/Users/GetListeningStats.cs b/backend/Core/Musify.Application/Users/GetListeningStats.cs index 56428d8..a54880a 100644 --- a/backend/Core/Musify.Application/Users/GetListeningStats.cs +++ b/backend/Core/Musify.Application/Users/GetListeningStats.cs @@ -39,6 +39,9 @@ public class GetListeningStatsQueryHandler(IDatabase database) var listenedDateSet = listenedDates.ToHashSet(); var streakDays = 0; var day = DateTime.UtcNow.Date; + if (!listenedDateSet.Contains(day)) + day = day.AddDays(-1); + while (listenedDateSet.Contains(day)) { streakDays++; diff --git a/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs b/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs index 82e2626..c505f0a 100644 --- a/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs +++ b/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs @@ -7,12 +7,11 @@ public class AlbumPictures [Required] public required string OriginalName { get; set; } - [Required] - public required string SmallName { get; set; } + public string? SmallName { get; set; } - [Required] - public required string MediumName { get; set; } + public string? MediumName { get; set; } - [Required] - public required string LargeName { get; set; } + public string? LargeName { get; set; } + + public static AlbumPictures Pending(string originalName) => new() { OriginalName = originalName }; } diff --git a/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs b/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs index df8dc3e..3617896 100644 --- a/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs +++ b/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs @@ -7,12 +7,11 @@ public class PlayListPictures [Required] public required string OriginalName { get; set; } - [Required] - public required string SmallName { get; set; } + public string? SmallName { get; set; } - [Required] - public required string MediumName { get; set; } + public string? MediumName { get; set; } - [Required] - public required string LargeName { get; set; } + public string? LargeName { get; set; } + + public static PlayListPictures Pending(string originalName) => new() { OriginalName = originalName }; } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/DeleteAlbumFromDbActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/DeleteAlbumFromDbActivity.cs new file mode 100644 index 0000000..37baf24 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/DeleteAlbumFromDbActivity.cs @@ -0,0 +1,35 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities.Albums; + +internal class DeleteAlbumFromDbActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity +{ + public const string ExecuteEndpointName = "delete-album-from-db"; + + public async Task Execute(ExecuteContext executeContext) + { + var album = await database.Albums + .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken); + + if (album == null) + { + logger.LogInformation( + "Album {AlbumId} not found while deleting from DB, skipping", + executeContext.Arguments.AlbumId); + return executeContext.Completed(); + } + + database.Albums.Remove(album); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Deleted album {AlbumId} from DB", album.Id); + return executeContext.Completed(); + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsRemovingActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsRemovingActivity.cs new file mode 100644 index 0000000..edec538 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsRemovingActivity.cs @@ -0,0 +1,36 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities.Albums; + +internal class MarkAlbumAsRemovingActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity +{ + public const string ExecuteEndpointName = "mark-album-as-removing"; + + public async Task Execute(ExecuteContext executeContext) + { + var album = await database.Albums + .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken); + + if (album == null) + { + logger.LogInformation( + "Album {AlbumId} not found while marking as removing, skipping", + executeContext.Arguments.AlbumId); + return executeContext.Completed(); + } + + album.LifeCycleStatus = LifeCycleStatus.Removing; + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Marked album {AlbumId} as removing", album.Id); + return executeContext.Completed(); + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs index ec0c953..b59c525 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs @@ -23,7 +23,8 @@ internal class DownloadFileFromBucketActivity( using var fileStream = await storageHandler.GetFileAsync( executeContext.Arguments.Bucket, executeContext.Arguments.Key, - executeContext.CancellationToken); + executeContext.CancellationToken) + ?? throw new FileNotFoundException($"Object {executeContext.Arguments.Bucket}/{executeContext.Arguments.Key} was not found."); var destinationDirectory = Path.GetDirectoryName(destinationPath); if (!string.IsNullOrWhiteSpace(destinationDirectory)) diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteAlbumFromDbArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteAlbumFromDbArguments.cs new file mode 100644 index 0000000..ec2a2ca --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteAlbumFromDbArguments.cs @@ -0,0 +1,4 @@ +namespace Musify.Infrastructure.MassTransit.Arguments; + +internal record DeleteAlbumFromDbArguments( + Guid AlbumId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsRemovingArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsRemovingArguments.cs new file mode 100644 index 0000000..65dc6d0 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsRemovingArguments.cs @@ -0,0 +1,4 @@ +namespace Musify.Infrastructure.MassTransit.Arguments; + +internal record MarkAlbumAsRemovingArguments( + Guid AlbumId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs new file mode 100644 index 0000000..cf9b4fc --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs @@ -0,0 +1,24 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class DeleteAlbumConsumer( + IBus bus, + DeleteAlbumRoutingSlipBuilder routingSlipBuilder) + : IConsumer +{ + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = (await routingSlipBuilder + .BuildAsync( + consumeContext.Message.AlbumId, + consumeContext.Message.UserId, + consumeContext.CorrelationId, + consumeContext.CancellationToken)) + .Build(); + + await bus.Execute(routingSlip); + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs index 9759c1b..678b679 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs @@ -22,6 +22,7 @@ public static partial class MassTransitDependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -35,6 +36,7 @@ public static partial class MassTransitDependencyInjection options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); + options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); @@ -58,6 +60,8 @@ public static partial class MassTransitDependencyInjection options.AddExecuteActivity(); options.AddExecuteActivity(); options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); options.AddExecuteActivity(); options.AddActivity(); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs index 7297012..d14f075 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs @@ -3,6 +3,6 @@ namespace Musify.Infrastructure.MassTransit.Logs; public record UpdateAlbumPictureLog( Guid AlbumId, string PreviousOriginalPictureKey, - string PreviousSmallPictureKey, - string PreviousMediumPictureKey, - string PreviousLargePictureKey); + 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 621bd87..8ee81b5 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs @@ -3,6 +3,6 @@ namespace Musify.Infrastructure.MassTransit.Logs; public record UpdatePlayListPictureLog( Guid PlayListId, string PreviousOriginalPictureKey, - string PreviousSmallPictureKey, - string PreviousMediumPictureKey, - string PreviousLargePictureKey); + string? PreviousSmallPictureKey, + string? PreviousMediumPictureKey, + string? PreviousLargePictureKey); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs index 6d44957..76d0a02 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs @@ -9,6 +9,8 @@ internal static class ActivityNames internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed"; internal const string DeletePlayListFromDb = "DeletePlayListFromDb"; internal const string MarkAlbumAsFailed = "MarkAlbumAsFailed"; + internal const string MarkAlbumAsRemoving = "MarkAlbumAsRemoving"; + internal const string DeleteAlbumFromDb = "DeleteAlbumFromDb"; internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths"; internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; @@ -55,4 +57,7 @@ internal static class ActivityNames internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture"; internal const string RemoveAlbumOriginalPicture = "RemoveAlbumOriginalPicture"; + internal const string RemoveAlbumSmallPicture = "RemoveAlbumSmallPicture"; + internal const string RemoveAlbumMediumPicture = "RemoveAlbumMediumPicture"; + internal const string RemoveAlbumLargePicture = "RemoveAlbumLargePicture"; } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteAlbumRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteAlbumRoutingSlipBuilder.cs new file mode 100644 index 0000000..1618709 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteAlbumRoutingSlipBuilder.cs @@ -0,0 +1,93 @@ +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.Albums; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Consumers; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +public class DeleteAlbumRoutingSlipBuilder( + IDatabase database, + AlbumConfiguration albumConfiguration, + ApplicationStorageConfiguration storageConfiguration) +{ + public async Task BuildAsync( + Guid albumId, + 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 album = await database.Albums + .AsNoTracking() + .SingleOrDefaultAsync(p => p.Id == albumId, cancellationToken); + + routingSlipBuilder.AddActivity( + ActivityNames.MarkAlbumAsRemoving, + EndpointHelper.BuildExecuteActivityUri(MarkAlbumAsRemovingActivity.ExecuteEndpointName), + new MarkAlbumAsRemovingArguments(albumId)); + + if (album?.Pictures != null) + { + var bucket = storageConfiguration.Bucket; + var routes = albumConfiguration.Routes; + + if (album.Pictures.OriginalName is { } originalName) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemoveAlbumOriginalPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildOriginalPicturePath(userId, originalName))); + } + + if (album.Pictures.SmallName is { } smallName) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemoveAlbumSmallPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildSmallPicturePath(smallName))); + } + + if (album.Pictures.MediumName is { } mediumName) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemoveAlbumMediumPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildMediumPicturePath(mediumName))); + } + + if (album.Pictures.LargeName is { } largeName) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemoveAlbumLargePicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildLargePicturePath(largeName))); + } + } + + routingSlipBuilder.AddActivity( + ActivityNames.DeleteAlbumFromDb, + EndpointHelper.BuildExecuteActivityUri(DeleteAlbumFromDbActivity.ExecuteEndpointName), + new DeleteAlbumFromDbArguments(albumId)); + + return routingSlipBuilder; + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index 1e84194..23a7e94 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -1,3 +1,4 @@ +using ErrorOr; using MassTransit; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; @@ -6,6 +7,7 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.MassTransit.Sagas; +using Npgsql; using AppIDatabase = Musify.Application.Contracts.IDatabase; namespace Musify.Infrastructure.Persistence; @@ -82,9 +84,9 @@ public class Database(DatabaseConfiguration configuration) .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); + pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false); }); modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); @@ -110,6 +112,10 @@ public class Database(DatabaseConfiguration configuration) .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) .IsUnique(); + modelBuilder.Entity() + .HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId }) + .IsUnique(); + modelBuilder.Entity() .HasIndex(mix => new { mix.UserId, mix.Position }); @@ -205,6 +211,19 @@ public class Database(DatabaseConfiguration configuration) return new DatabaseTransaction(transaction); } + public async Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken) + { + try + { + await SaveChangesAsync(cancellationToken); + return Result.Success; + } + catch (DbUpdateException exception) when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) + { + return onUniqueViolation; + } + } + private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction { public Task CommitAsync(CancellationToken cancellationToken) => diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.Designer.cs new file mode 100644 index 0000000..c1b855b --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.Designer.cs @@ -0,0 +1,1174 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260924030938_PlayListTrackUnique")] + partial class PlayListTrackUnique + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsCounted") + .HasColumnType("boolean"); + + b.Property("LastProgressAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayedSeconds") + .HasColumnType("double precision"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("PlayListId", "TrackId") + .IsUnique(); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId", "TrackId") + .IsUnique(); + + b.ToTable("TrackLikes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowedId") + .HasColumnType("bigint"); + + b.Property("FollowerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FollowedId"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique(); + + b.ToTable("UserFollows"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("DownloadRequested") + .HasColumnType("boolean") + .HasColumnName("DownloadRequested"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("LastRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("LastRetryAt"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("RetryCount"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.HasOne("Musify.Domain.Entities.User", "Followed") + .WithMany() + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("Tags"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.cs new file mode 100644 index 0000000..14525bd --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class PlayListTrackUnique : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Remove duplicate rows (keeping the lowest position) before enforcing uniqueness. + migrationBuilder.Sql(""" + DELETE FROM "PlayListHasTrack" a + USING "PlayListHasTrack" b + WHERE a."PlayListId" = b."PlayListId" + AND a."TrackId" = b."TrackId" + AND (a."Position", a."Id") > (b."Position", b."Id"); + """); + + migrationBuilder.DropIndex( + name: "IX_PlayListHasTrack_PlayListId", + table: "PlayListHasTrack"); + + migrationBuilder.CreateIndex( + name: "IX_PlayListHasTrack_PlayListId_TrackId", + table: "PlayListHasTrack", + columns: new[] { "PlayListId", "TrackId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_PlayListHasTrack_PlayListId_TrackId", + table: "PlayListHasTrack"); + + migrationBuilder.CreateIndex( + name: "IX_PlayListHasTrack_PlayListId", + table: "PlayListHasTrack", + column: "PlayListId"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index bf0434b..c708d49 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -417,10 +417,11 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("Id"); - b.HasIndex("PlayListId"); - b.HasIndex("TrackId"); + b.HasIndex("PlayListId", "TrackId") + .IsUnique(); + b.ToTable("PlayListHasTrack"); }); @@ -790,13 +791,11 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasColumnType("uuid"); b1.Property("LargeName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("LargePictureName"); b1.Property("MediumName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("MediumPictureName"); @@ -808,7 +807,6 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasColumnName("OriginalPictureName"); b1.Property("SmallName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("SmallPictureName"); diff --git a/backend/Core/Musify.Infrastructure/Services/StorageService.cs b/backend/Core/Musify.Infrastructure/Services/StorageService.cs index 8be9c1a..69e9855 100644 --- a/backend/Core/Musify.Infrastructure/Services/StorageService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StorageService.cs @@ -15,7 +15,7 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger GetFileAsync(string bucket, string key, CancellationToken cancellationToken) + public async Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken) { var request = new GetObjectRequest { @@ -29,6 +29,11 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger>() .WithName("GetAlbums") .WithSummary("Get Paginated Albums, Optionally Filtered By Title.") .Produces(); @@ -30,6 +34,7 @@ public static class AlbumEndpoints .Produces(StatusCodes.Status404NotFound); group.MapGet("/users/{userId}", GetAlbumsByUserId) + .AddEndpointFilter>() .WithName("GetAlbumsByUserId") .WithSummary("Get Paginated Albums For A User.") .Produces>() @@ -43,6 +48,7 @@ public static class AlbumEndpoints .Produces(StatusCodes.Status401Unauthorized); group.MapGet("/{albumId}/tracks", GetAlbumTracks) + .AddEndpointFilter>() .WithName("GetAlbumTracks") .WithSummary("Get The Tracks Of An Album Ordered By Track Number.") .Produces>() @@ -82,6 +88,7 @@ public static class AlbumEndpoints .Produces() .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); group.MapDelete("/{albumId}", DeleteAlbum) @@ -90,6 +97,7 @@ public static class AlbumEndpoints .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); group.MapPost("/{albumId}/tracks/{trackId}", AddTrackToAlbum) @@ -98,6 +106,7 @@ public static class AlbumEndpoints .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); @@ -107,6 +116,7 @@ public static class AlbumEndpoints .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); return app; @@ -116,10 +126,9 @@ public static class AlbumEndpoints IMediator mediator, CancellationToken cancellationToken, string? title, - int pageNumber = 1, - int pageSize = 10) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetAlbumsQuery(title, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetAlbumsQuery(title, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); } @@ -136,10 +145,9 @@ public static class AlbumEndpoints IMediator mediator, long userId, CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 10) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); } @@ -149,6 +157,14 @@ public static class AlbumEndpoints CancellationToken cancellationToken, int limit = 12) { + if (limit is < 1 or > MaxRecentAlbumsLimit) + { + return Results.ValidationProblem(new Dictionary + { + [nameof(limit)] = [$"'{nameof(limit)}' must be between 1 and {MaxRecentAlbumsLimit}."] + }); + } + var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken); return result.ToHttpResult(); } @@ -157,10 +173,9 @@ public static class AlbumEndpoints IMediator mediator, Guid albumId, CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 50) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetAlbumTracksQuery(albumId, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetAlbumTracksQuery(albumId, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); } @@ -178,6 +193,9 @@ public static class AlbumEndpoints var location = result.Value; var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); + if (stream == null) + return Results.NotFound(); + response.Headers.CacheControl = "public, max-age=31536000, immutable"; return Results.Stream(stream, location.ContentType); } diff --git a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs index cd2fc5f..4c74e53 100644 --- a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs @@ -3,8 +3,10 @@ using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.Likes; using Musify.Api.Extensions; using Musify.Application.Likes; +using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; +using Musify.Api.Filters; namespace Musify.Api.Endpoints; @@ -17,6 +19,7 @@ public static class LikeEndpoints .RequireAuthorization(); group.MapGet("/", GetLikedTracks) + .AddEndpointFilter>() .WithName("GetLikedTracks") .WithSummary("Get The Current User'S Liked Tracks.") .Produces>() @@ -36,10 +39,9 @@ public static class LikeEndpoints IMediator mediator, CurrentUser currentUser, CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 10) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index 2fd1d69..dd57a99 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -6,6 +6,7 @@ using Musify.Api.Filters; using Musify.Application.Contracts; using Musify.Application.PlayLists; using Musify.Application.PlayLists.Responses; +using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; @@ -26,11 +27,13 @@ public static class PlayListEndpoints .Produces(StatusCodes.Status404NotFound); group.MapGet("/users/{userId}", GetPlayListsByUserId) + .AddEndpointFilter>() .WithName("GetPlayListsByUserId") .WithSummary("Get Paginated PlayLists For A User. Private PlayLists Are Only Returned To Their Owner.") .Produces>(); group.MapGet("/{playlistId}/tracks", GetPlayListTracks) + .AddEndpointFilter>() .WithName("GetPlayListTracks") .WithSummary("Get Paginated Tracks Of A PlayList.") .Produces>() @@ -60,6 +63,7 @@ public static class PlayListEndpoints .Produces() .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); group.MapDelete("/{playlistId}", DeletePlayList) @@ -68,6 +72,7 @@ public static class PlayListEndpoints .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); group.MapPost("/upload-picture", RequestPlayListPictureUpload) @@ -86,6 +91,7 @@ public static class PlayListEndpoints .RequireAuthorization() .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); @@ -95,6 +101,7 @@ public static class PlayListEndpoints .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); return app; @@ -116,10 +123,9 @@ public static class PlayListEndpoints long userId, CancellationToken cancellationToken, string? name, - int pageNumber = 1, - int pageSize = 10) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize, currentUser.Id), cancellationToken); + var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); return result.ToHttpResult(); } @@ -128,10 +134,9 @@ public static class PlayListEndpoints CurrentUser currentUser, Guid playlistId, CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 10) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, pageNumber, pageSize, currentUser.Id), cancellationToken); + var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); return result.ToHttpResult(); } @@ -149,6 +154,9 @@ public static class PlayListEndpoints var location = result.Value; var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); + if (stream == null) + return Results.NotFound(); + response.Headers.CacheControl = "public, max-age=31536000, immutable"; return Results.Stream(stream, location.ContentType); } diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index f14b9f7..dcbf00b 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -4,6 +4,7 @@ using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.Extensions; using Musify.Api.Filters; using Musify.Application.Contracts; +using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; @@ -19,6 +20,7 @@ public static class TrackEndpoints .WithTags("Tracks"); group.MapGet("/", GetTracks) + .AddEndpointFilter>() .WithName("GetTracks") .WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.") .Produces() @@ -52,6 +54,7 @@ public static class TrackEndpoints .Produces(StatusCodes.Status404NotFound); group.MapGet("/users/{userId}", GetTracksByUserId) + .AddEndpointFilter>() .WithName("GetTracksByUserId") .WithSummary("Get Paginated Tracks For A User.") .Produces>(); @@ -73,6 +76,7 @@ public static class TrackEndpoints .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); @@ -95,8 +99,7 @@ public static class TrackEndpoints CancellationToken cancellationToken, string? name, string? genre, - int pageNumber = 1, - int pageSize = 10) + [AsParameters] PageQuery page) { Genre? parsedGenre = null; if (!string.IsNullOrWhiteSpace(genre)) @@ -112,7 +115,7 @@ public static class TrackEndpoints parsedGenre = value; } - var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, parsedGenre), cancellationToken); + var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, parsedGenre), cancellationToken); return result.ToHttpResult(); } @@ -139,6 +142,9 @@ public static class TrackEndpoints var location = result.Value; var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); + if (stream == null) + return Results.NotFound(); + response.Headers.CacheControl = "public, max-age=31536000, immutable"; return Results.Stream(stream, location.ContentType); } @@ -171,10 +177,9 @@ public static class TrackEndpoints long userId, CancellationToken cancellationToken, string? name, - int pageNumber = 1, - int pageSize = 10) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs index 3c0029a..af38b56 100644 --- a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -1,11 +1,13 @@ using Mediator; using Musify.Api.Authentication; using Musify.Api.Extensions; +using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; using Musify.Application.Users; using Musify.Application.Users.Responses; +using Musify.Api.Filters; namespace Musify.Api.Endpoints; @@ -17,6 +19,7 @@ public static class UserEndpoints .WithTags("Users"); group.MapGet("/", GetUsers) + .AddEndpointFilter>() .WithName("GetUsers") .WithSummary("Get Paginated Users.") .Produces>(); @@ -45,7 +48,7 @@ public static class UserEndpoints .WithName("GetLastTrackListenedByUserId") .WithSummary("Get The Last Track A User Listened To.") .Produces() - .Produces(StatusCodes.Status404NotFound); + .Produces(StatusCodes.Status204NoContent); group.MapGet("/{id}/profile", GetUserProfile) .WithName("GetUserProfile") @@ -54,6 +57,7 @@ public static class UserEndpoints .Produces(StatusCodes.Status404NotFound); group.MapGet("/{id}/followers", GetFollowers) + .AddEndpointFilter>() .WithName("GetUserFollowers") .WithSummary("Get The Users Following A User. Visible To The User And To Mutual Followers.") .Produces>() @@ -61,6 +65,7 @@ public static class UserEndpoints .Produces(StatusCodes.Status404NotFound); group.MapGet("/{id}/following", GetFollowing) + .AddEndpointFilter>() .WithName("GetUserFollowing") .WithSummary("Get The Users A User Follows.") .Produces>() @@ -97,10 +102,9 @@ public static class UserEndpoints CurrentUser currentUser, CancellationToken cancellationToken, string? usernameSearch, - int pageNumber = 1, - int pageSize = 10) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch, currentUser.Id), cancellationToken); + var result = await mediator.Send(new GetUsersQuery(page.PageNumber, page.PageSize, usernameSearch, currentUser.Id), cancellationToken); return result.ToHttpResult(); } @@ -138,8 +142,8 @@ public static class UserEndpoints long id, CancellationToken cancellationToken) { - var result = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken); - return result.ToHttpResult(); + var track = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken); + return track == null ? Results.NoContent() : Results.Ok(track); } private static async Task GetUserProfile( @@ -157,10 +161,9 @@ public static class UserEndpoints CurrentUser currentUser, long id, CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 20) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); } @@ -169,10 +172,9 @@ public static class UserEndpoints CurrentUser currentUser, long id, CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 20) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs b/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs index 33d2da5..d6e75ff 100644 --- a/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs +++ b/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs @@ -28,10 +28,10 @@ public static class ErrorOrHttpExtensions var error = errors[0]; return error.Type switch { - ErrorType.NotFound => Results.NotFound(error.Description), + ErrorType.NotFound => Results.Problem(error.Description, statusCode: StatusCodes.Status404NotFound), ErrorType.Unauthorized => Results.Unauthorized(), - ErrorType.Forbidden => Results.StatusCode(StatusCodes.Status403Forbidden), - ErrorType.Conflict => Results.Conflict(error.Description), + ErrorType.Forbidden => Results.Problem(error.Description, statusCode: StatusCodes.Status403Forbidden), + ErrorType.Conflict => Results.Problem(error.Description, statusCode: StatusCodes.Status409Conflict), _ => Results.Problem(error.Description) }; } diff --git a/backend/Hosts/Musify.Api/Models/PageQuery.cs b/backend/Hosts/Musify.Api/Models/PageQuery.cs new file mode 100644 index 0000000..888a064 --- /dev/null +++ b/backend/Hosts/Musify.Api/Models/PageQuery.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc; +using Musify.Application.Shared; + +namespace Musify.Api.Models; + +/// Query-string binding for , keeping the camelCase parameter names of the API. +public record PageQuery( + [property: FromQuery(Name = "pageNumber")] int PageNumber = 1, + [property: FromQuery(Name = "pageSize")] int PageSize = 20); diff --git a/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs b/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs new file mode 100644 index 0000000..2ae656a --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Musify.Api.Models; +using Musify.Application.Shared; + +namespace Musify.Api.Validators; + +public sealed class PageQueryValidator : AbstractValidator +{ + public PageQueryValidator() + { + RuleFor(x => x.PageNumber) + .GreaterThanOrEqualTo(1); + + RuleFor(x => x.PageSize) + .InclusiveBetween(1, PageRequest.MaxPageSize); + } +} diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs index b0003b7..c28c91e 100644 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs @@ -13,5 +13,8 @@ public sealed class CreatePlayListRequestValidator : AbstractValidator x.Description) .MaximumLength(256); + + RuleFor(x => x.Visibility) + .IsInEnum(); } } diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs index c886937..c7bbc96 100644 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs @@ -15,5 +15,9 @@ public sealed class UpdatePlayListRequestValidator : AbstractValidator x.NewDescription) .MaximumLength(256) .When(x => x.NewDescription != null); + + RuleFor(x => x.NewVisibility) + .IsInEnum() + .When(x => x.NewVisibility != null); } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index 49d0115..68745e8 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -95,7 +95,7 @@ public sealed class AlbumEndpointsTests(ApiTestFixture fixture) } [Fact] - public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized() + public async Task DeleteAlbum_NotTheOwner_ReturnsForbidden() { var owner = await CreateUserAsync(); var stranger = await CreateUserAsync(); @@ -106,7 +106,7 @@ public sealed class AlbumEndpointsTests(ApiTestFixture fixture) var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs index 9d27991..62dd90e 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs @@ -38,7 +38,8 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture) { UserId = userId, TrackId = track.Id, - ListenedAt = listenedAt + ListenedAt = listenedAt, + IsCounted = true }); await database.SaveChangesAsync(TestContext.Current.CancellationToken); } @@ -94,7 +95,7 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture) } [Fact] - public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNotFound() + public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNoContent() { var client = fixture.CreateAnonymousClient(); var userId = Random.Shared.NextInt64(1, long.MaxValue); @@ -102,6 +103,39 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture) var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Theory] + [InlineData("pageSize=0")] + [InlineData("pageSize=101")] + [InlineData("pageNumber=0")] + public async Task GetUsers_InvalidPaging_ReturnsBadRequest(string query) + { + var client = fixture.CreateAnonymousClient(); + + var response = await client.GetAsync($"/users?{query}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetListeningStats_AnotherUser_ReturnsForbidden() + { + var userId = await fixture.SeedUserAsync(); + var other = await fixture.SeedUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + + var response = await client.GetAsync($"/users/{other}/listening-stats", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task GetListeningStats_Anonymous_ReturnsUnauthorized() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/users/1/listening-stats", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs index 77caf56..784c48e 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs @@ -39,7 +39,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -49,7 +49,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase 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.Forbidden, result.FirstError.Type); } [Fact] @@ -65,7 +65,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_TrackOwnedByAnotherUser_ReturnsUnauthorized() + public async Task Handle_TrackOwnedByAnotherUser_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var otherOwner = TestEntities.User(2, "other"); @@ -75,7 +75,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase 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.Forbidden, result.FirstError.Type); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs index 98c5d27..f6ab013 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs @@ -1,16 +1,21 @@ using ErrorOr; using Musify.Application.Albums; +using Musify.Application.Contracts; using Musify.Application.Tests.TestSupport; +using Musify.Domain.ValueObjects; +using NSubstitute; using Xunit; namespace Musify.Application.Tests.Albums; public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase { - private DeleteAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); + private readonly IEventBus eventBus = Substitute.For(); + + private DeleteAlbumCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); [Fact] - public async Task Handle_Owner_RemovesAlbum() + public async Task Handle_Owner_MarksAlbumAsRemoving() { var owner = TestEntities.User(); var album = TestEntities.Album(owner.Id); @@ -19,7 +24,10 @@ public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase 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)); + var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -31,7 +39,7 @@ public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorizedAndKeepsAlbum() + public async Task Handle_NotOwner_ReturnsForbiddenAndKeepsAlbum() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -40,7 +48,7 @@ public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken)); } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs index 60a732d..4b84e3d 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs @@ -34,4 +34,19 @@ public sealed class GetAlbumCoverQueryHandlerTests : HandlerTestBase Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } + + [Fact] + public async Task Handle_PicturesStillProcessing_FallsBackToTheOriginal() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id, pictures: Musify.Domain.ValueObjects.AlbumPictures.Pending("original.png")); + await SeedAsync(owner, album); + + var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, "small"), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Contains($"{owner.Id}", result.Value.Key); + Assert.EndsWith("original.png", result.Value.Key); + Assert.Equal("image/png", result.Value.ContentType); + } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs index c792fe9..60be72f 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs @@ -46,7 +46,7 @@ public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -55,7 +55,7 @@ public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase 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.Forbidden, result.FirstError.Type); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs index bd46886..b1c6695 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs @@ -77,7 +77,7 @@ public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -88,6 +88,6 @@ public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs index bf5322f..fd3626b 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs @@ -53,7 +53,7 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -62,7 +62,7 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase 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.Forbidden, result.FirstError.Type); } [Fact] @@ -89,4 +89,18 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase Assert.Equal(ErrorType.Conflict, result.FirstError.Type); } + + [Fact] + public async Task TrySaveChangesAsync_DuplicatePlayListTrack_MapsTheUniqueViolationToTheGivenError() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var track = TestEntities.Track(owner); + await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); + Database.PlayListHasTracks.Add(new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 1 }); + + var result = await Database.TrySaveChangesAsync(Error.Conflict("Test.Duplicate", "duplicate"), TestContext.Current.CancellationToken); + + Assert.Equal("Test.Duplicate", result.FirstError.Code); + } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs index 7965b4e..d8559c7 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs @@ -39,7 +39,7 @@ public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -48,6 +48,6 @@ public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs index 2acaa5b..c996a45 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs @@ -33,7 +33,7 @@ public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -42,7 +42,7 @@ public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase 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.Forbidden, result.FirstError.Type); } [Fact] @@ -56,4 +56,26 @@ public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } + + [Fact] + public async Task Handle_TrackInTheMiddle_RenumbersTheRemainingTracks() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + var third = TestEntities.Track(owner, "Third"); + await SeedAsync( + owner, playList, first, second, third, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 }, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 }, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = third.Id, Position = 2 }); + + var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, second.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var remaining = Database.PlayListHasTracks.OrderBy(link => link.Position).ToList(); + Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId)); + Assert.Equal([0, 1], remaining.Select(link => link.Position)); + } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs index 356c69d..fc3c8e7 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs @@ -48,7 +48,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -59,7 +59,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] @@ -93,7 +93,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_BlankNameAndDescription_LeavesThemUnchanged() + public async Task Handle_BlankName_LeavesItUnchangedAndBlankDescriptionClearsIt() { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description"); @@ -105,7 +105,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase Assert.False(result.IsError); Assert.Equal("Original Name", result.Value.Name); - Assert.Equal("Original description", result.Value.Description); + Assert.Null(result.Value.Description); } [Fact] @@ -126,7 +126,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Equal("new-cover.webp", result.Value.SmallImageKeyName); + Assert.Null(result.Value.SmallImageKeyName); var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken); Assert.NotNull(stored); Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName); diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs index 6ddf063..c891090 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -1,3 +1,4 @@ +using ErrorOr; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; @@ -79,6 +80,10 @@ public sealed class TestDatabase : DbContext, IDatabase .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) .IsUnique(); + modelBuilder.Entity() + .HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId }) + .IsUnique(); + modelBuilder.Entity() .HasIndex(mix => new { mix.UserId, mix.Position }); @@ -164,6 +169,19 @@ public sealed class TestDatabase : DbContext, IDatabase return new SqliteDatabaseTransaction(transaction); } + public async Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken) + { + try + { + await SaveChangesAsync(cancellationToken); + return Result.Success; + } + catch (DbUpdateException exception) when (exception.InnerException is SqliteException { SqliteErrorCode: 19 }) + { + return onUniqueViolation; + } + } + public override async ValueTask DisposeAsync() { await base.DisposeAsync(); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs index 00163a1..4052fe0 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs @@ -39,7 +39,7 @@ public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -48,7 +48,7 @@ public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs index 88965d9..822db75 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs @@ -1,4 +1,3 @@ -using ErrorOr; using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; @@ -22,18 +21,18 @@ public sealed class GetLastTrackListenedByUserIdQueryHandlerTests : HandlerTestB var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - Assert.False(result.IsError); - Assert.Equal("Newer listen", result.Value.Title); + Assert.NotNull(result); + Assert.Equal("Newer listen", result.Title); } [Fact] - public async Task Handle_NoHistory_ReturnsNotFound() + public async Task Handle_NoHistory_ReturnsNull() { var owner = TestEntities.User(); await SeedAsync(owner); var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + Assert.Null(result); } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs index 9be31e2..2db7b57 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs @@ -27,19 +27,4 @@ public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase 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 }); - - var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: requestedPageNumber, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(expectedPageNumber, result.Value.PageNumber); - } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs index ebca2ff..77a3850 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs @@ -102,7 +102,7 @@ public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NoListenToday_StreakIsZero() + public async Task Handle_ListenedYesterdayButNotToday_StreakIsStillAlive() { var user = TestEntities.User(); var track = TestEntities.Track(user); @@ -112,6 +112,20 @@ public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + Assert.Equal(1, result.StreakDays); + } + + [Fact] + public async Task Handle_NoListenTodayOrYesterday_StreakIsZero() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2))); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + Assert.Equal(0, result.StreakDays); } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs index bee7c06..80bbcc1 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs @@ -42,12 +42,21 @@ public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAs await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + Assert.NotNull(stream); using var reader = new StreamReader(stream, Encoding.UTF8); var readBack = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); Assert.Equal("hello seaweedfs", readBack); } + [Fact] + public async Task GetFileAsync_MissingObject_ReturnsNull() + { + var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken); + + Assert.Null(stream); + } + [Fact] public async Task HeadObjectAsync_ExistingObject_ReturnsMetadata() { diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 7fe2d25..57fb27d 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -1396,6 +1396,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -1430,6 +1437,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -1465,6 +1479,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -1507,6 +1528,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -1825,6 +1853,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -1957,6 +1992,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -1991,6 +2033,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -2073,6 +2122,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -2353,6 +2409,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -2561,8 +2624,8 @@ export interface operations { "application/json": components["schemas"]["TrackApplicationResponse"]; }; }; - /** @description Not Found */ - 404: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; diff --git a/web-player/src/lib/config.ts b/web-player/src/lib/config.ts index 8b94487..7c2eb72 100644 --- a/web-player/src/lib/config.ts +++ b/web-player/src/lib/config.ts @@ -3,7 +3,7 @@ export const SEARCH_DEBOUNCE_MS = 450; export const SEARCH_MIN_LENGTH = 2; export const INFINITE_SCROLL_ROOT_MARGIN = '300px'; export const ALBUMS_PAGE_SIZE = 50; -export const ALBUM_TRACKS_PAGE_SIZE = 200; +export const ALBUM_TRACKS_PAGE_SIZE = 100; export const ALBUM_EARLIEST_YEAR = 1877; export const LIBRARY_PICKER_PAGE_SIZE = 50; export const EXPLORE_ALBUMS_PAGE_SIZE = 10; @@ -15,7 +15,8 @@ export const HOME_CONTINUE_LIMIT = 10; export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5; export const HOME_POPULAR_MAX = 8; export const PLAYLIST_PICKER_PAGE_SIZE = 50; -export const LIKED_TRACKS_PAGE_SIZE = 500; +export const LIKED_TRACKS_LIMIT = 500; +export const PLAYLIST_TRACKS_LIMIT = 500; export const EXPLORE_USERS_LIMIT = 12; export const FOLLOW_LIST_PAGE_SIZE = 30; export const MENU_WIDTH = 240; diff --git a/web-player/src/lib/server/pagination.ts b/web-player/src/lib/server/pagination.ts new file mode 100644 index 0000000..f8805b1 --- /dev/null +++ b/web-player/src/lib/server/pagination.ts @@ -0,0 +1,22 @@ +/** Largest page size the API accepts (`PageRequest.MaxPageSize` in the backend). */ +export const API_MAX_PAGE_SIZE = 100; + +type Page = { items: T[]; hasNextPage: boolean }; + +/** Loads consecutive pages until the list ends or `maxItems` items were collected. */ +export async function fetchAllPages( + loadPage: (pageNumber: number, pageSize: number) => Promise | undefined>, + maxItems: number +): Promise { + const items: T[] = []; + + for (let pageNumber = 1; items.length < maxItems; pageNumber++) { + const page = await loadPage(pageNumber, API_MAX_PAGE_SIZE); + if (!page) break; + + items.push(...page.items); + if (!page.hasNextPage) break; + } + + return items.slice(0, maxItems); +} diff --git a/web-player/src/routes/(app)/playlists/+page.server.ts b/web-player/src/routes/(app)/playlists/+page.server.ts index 5349245..8b41e64 100644 --- a/web-player/src/routes/(app)/playlists/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/+page.server.ts @@ -7,6 +7,8 @@ import { unwrapOrError } from '$lib/server/api'; import { uploadPresignedImage } from '$lib/server/upload'; +import { fetchAllPages } from '$lib/server/pagination'; +import { PLAYLIST_TRACKS_LIMIT } from '$lib/config'; const MAX_NAME = 100; @@ -21,11 +23,15 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { const tracksByPlaylist = await Promise.all( data.items.map((playlist) => - api - .GET('/playlists/{playlistId}/tracks', { - params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 500 } } - }) - .then((res) => res.data?.items ?? []) + fetchAllPages( + async (pageNumber, pageSize) => + ( + await api.GET('/playlists/{playlistId}/tracks', { + params: { path: { playlistId: playlist.id }, query: { pageNumber, pageSize } } + }) + ).data, + PLAYLIST_TRACKS_LIMIT + ) ) ); diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts index fd32656..32882b6 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts @@ -8,6 +8,8 @@ import { unwrapOrFail } from '$lib/server/api'; import { uploadPresignedImage } from '$lib/server/upload'; +import { fetchAllPages } from '$lib/server/pagination'; +import { PLAYLIST_TRACKS_LIMIT } from '$lib/config'; const MAX_NAME = 100; @@ -16,15 +18,20 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent optionalUser(locals, url, allowAnonymousListening); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); - const [playlistRes, tracksRes] = await Promise.all([ + const [playlistRes, tracks] = await Promise.all([ api.GET('/playlists/{id}', { params: { path: { id: params.id } } }), - api.GET('/playlists/{playlistId}/tracks', { - params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } } - }) + fetchAllPages( + async (pageNumber, pageSize) => + ( + await api.GET('/playlists/{playlistId}/tracks', { + params: { path: { playlistId: params.id }, query: { pageNumber, pageSize } } + }) + ).data, + PLAYLIST_TRACKS_LIMIT + ) ]); const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404); - const tracks = tracksRes.data?.items ?? []; return { playlist, tracks }; }; diff --git a/web-player/src/routes/+layout.server.ts b/web-player/src/routes/+layout.server.ts index caf325d..98e3283 100644 --- a/web-player/src/routes/+layout.server.ts +++ b/web-player/src/routes/+layout.server.ts @@ -3,7 +3,8 @@ import type { LayoutServerLoad } from './$types'; import { getAllowAnonymousListening } from '$lib/server/playbackConfig'; import { createApiClient } from '$lib/server/api'; import { authConfig } from '$lib/server/config'; -import { LIKED_TRACKS_PAGE_SIZE } from '$lib/config'; +import { LIKED_TRACKS_LIMIT } from '$lib/config'; +import { fetchAllPages } from '$lib/server/pagination'; const PUBLIC_PATHS = new Set(['/auth']); const SIDEBAR_PLAYLISTS_LIMIT = 8; @@ -30,10 +31,11 @@ async function fetchSidebarPlaylists( async function fetchLikedTracks(fetchFn: typeof fetch, accessToken: string | null) { try { const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined }); - const { data } = await api.GET('/likes', { - params: { query: { pageNumber: 1, pageSize: LIKED_TRACKS_PAGE_SIZE } } - }); - return data?.items ?? []; + return await fetchAllPages( + async (pageNumber, pageSize) => + (await api.GET('/likes', { params: { query: { pageNumber, pageSize } } })).data, + LIKED_TRACKS_LIMIT + ); } catch { return []; } From 5e819d76762428a5d3e1adb13bdc35f820ad65e6 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 05:19:12 +0200 Subject: [PATCH 05/21] Remove dead code: unused storage/transcoder members, Upload table, retry columns, redundant packages --- .../Musify.Application/Albums/UpdateAlbum.cs | 10 +- .../Contracts/IAudioTranscoderService.cs | 2 - .../Musify.Application/Contracts/IDatabase.cs | 2 - .../Contracts/IStorageService.cs | 4 - .../PlayLists/UpdatePlayList.cs | 10 +- .../Core/Musify.Application/Users/GetUsers.cs | 3 +- backend/Core/Musify.Domain/Entities/Upload.cs | 28 - .../Musify.Domain/ValueObjects/TrackAudio.cs | 6 - .../Musify.Domain/ValueObjects/UploadState.cs | 8 - .../Infrastructure.csproj | 4 +- .../Jobs/UploadIntentExpirationJob.cs | 23 +- .../Files/DownloadFileFromUrlActivity.cs | 68 - .../Pictures/UpdateAlbumPictureActivity.cs | 2 - .../Pictures/UpdatePlayListPictureActivity.cs | 2 - .../Pictures/UpdateTrackPictureActivity.cs | 2 - .../Arguments/DownloadFileFromUrlArguments.cs | 5 - ...TransitDependencyInjection.Registration.cs | 1 - .../Logs/DownloadFileFromUrlLog.cs | 5 - .../MassTransit/RoutingSlip/ActivityNames.cs | 1 - .../Persistence/Database.cs | 8 +- .../Persistence/DatabaseDesignTimeFactory.cs | 4 - ...TableAndTrackAudioRetryColumns.Designer.cs | 1133 +++++++++++++++++ ...ropUploadTableAndTrackAudioRetryColumns.cs | 70 + .../Migrations/DatabaseModelSnapshot.cs | 41 - .../Services/AudioTranscoderService.cs | 44 - .../Services/ServicesDependencyInjection.cs | 2 +- .../Services/SingleFlightCache.cs | 40 - .../Services/StorageService.cs | 88 +- backend/Hosts/Musify.Api/Api.csproj | 17 - .../Musify.Api/Authentication/CurrentUser.cs | 7 - backend/Hosts/Musify.Api/appsettings.json | 18 - backend/Hosts/Musify.Worker/Program.cs | 3 +- backend/Hosts/Musify.Worker/Worker.csproj | 9 - backend/Hosts/Musify.Worker/appsettings.json | 3 - .../Authentication/CurrentUserTests.cs | 18 - .../TestSupport/TestDatabase.cs | 2 - .../Services/SingleFlightCacheTests.cs | 80 -- .../Services/StorageServiceTests.cs | 22 +- deploy/compose.yml | 2 - docs/architecture.md | 1 - docs/development.md | 2 +- 41 files changed, 1230 insertions(+), 570 deletions(-) delete mode 100644 backend/Core/Musify.Domain/Entities/Upload.cs delete mode 100644 backend/Core/Musify.Domain/ValueObjects/UploadState.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromUrlActivity.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromUrlArguments.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromUrlLog.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924031745_DropUploadTableAndTrackAudioRetryColumns.Designer.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924031745_DropUploadTableAndTrackAudioRetryColumns.cs delete mode 100644 backend/Core/Musify.Infrastructure/Services/SingleFlightCache.cs delete mode 100644 backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index 3c23fea..f03bfcf 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -55,9 +55,6 @@ public class UpdateAlbumCommandHandler( album.Description = request.Description; album.ReleaseYear = request.ReleaseYear; - UploadIntent? pictureIntent = null; - string? finalPictureKey = null; - if (request.NewPictureIntentId.HasValue) { var validation = await uploadIntentValidator.ValidateAndLoadAsync( @@ -66,13 +63,10 @@ public class UpdateAlbumCommandHandler( if (validation.IsError) return validation.Errors; - pictureIntent = validation.Value; + var pictureIntent = validation.Value; album.Pictures = AlbumPictures.Pending(pictureIntent.ObjectName); - finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); - } + var 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) diff --git a/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs b/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs index 3f0558a..29f8b81 100644 --- a/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs +++ b/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs @@ -3,6 +3,4 @@ namespace Musify.Application.Contracts; 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 de3b8e2..9bed6b1 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabase.cs @@ -24,8 +24,6 @@ public interface IDatabase public DbSet MixItems { get; } - public DbSet Uploads { get; } - public DbSet UploadIntents { get; } public DbSet ListeningHistories { get; } diff --git a/backend/Core/Musify.Application/Contracts/IStorageService.cs b/backend/Core/Musify.Application/Contracts/IStorageService.cs index ea79bdb..c5c0a70 100644 --- a/backend/Core/Musify.Application/Contracts/IStorageService.cs +++ b/backend/Core/Musify.Application/Contracts/IStorageService.cs @@ -6,8 +6,6 @@ public interface IStorageService public Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); - public Task GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken); - public Task GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken, bool preventOverwrite = true); public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken); @@ -21,7 +19,5 @@ public interface IStorageService public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, CancellationToken cancellationToken); - 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/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index d497e12..9516d44 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -63,9 +63,6 @@ public class UpdatePlayListCommandHandler( playListEntity.Visibility = request.NewVisibility.Value; } - UploadIntent? pictureIntent = null; - string? finalPictureKey = null; - if (request.NewPictureIntentId.HasValue) { var validation = await uploadIntentValidator.ValidateAndLoadAsync( @@ -74,13 +71,10 @@ public class UpdatePlayListCommandHandler( if (validation.IsError) return validation.Errors; - pictureIntent = validation.Value; + var pictureIntent = validation.Value; playListEntity.Pictures = PlayListPictures.Pending(pictureIntent.ObjectName); - finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); - } + var 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) diff --git a/backend/Core/Musify.Application/Users/GetUsers.cs b/backend/Core/Musify.Application/Users/GetUsers.cs index 2d17ebf..cbe48bb 100644 --- a/backend/Core/Musify.Application/Users/GetUsers.cs +++ b/backend/Core/Musify.Application/Users/GetUsers.cs @@ -17,8 +17,7 @@ public class GetUsersQueryHandler(IDatabase database) public async ValueTask>> Handle(GetUsersQuery request, CancellationToken cancellationToken) { var usersQuery = database.Users - .AsNoTracking() - .AsQueryable(); + .AsNoTracking(); if (!string.IsNullOrEmpty(request.UsernameSearch)) { diff --git a/backend/Core/Musify.Domain/Entities/Upload.cs b/backend/Core/Musify.Domain/Entities/Upload.cs deleted file mode 100644 index 5c8db80..0000000 --- a/backend/Core/Musify.Domain/Entities/Upload.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -using Musify.Domain.ValueObjects; - -namespace Musify.Domain.Entities; - -[Table("Upload")] -public class Upload -{ - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); - - [Required] - public required string Bucket { get; set; } - - [Required] - public required string Key { get; set; } - - [Required] - public required string ContentType { get; set; } - - [Required] - public DateTime CreatedTime { get; set; } = DateTime.UtcNow; - - [Required] - public UploadState State { get; set; } = UploadState.Pending; -} diff --git a/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs b/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs index c1c3148..a558d13 100644 --- a/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs +++ b/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs @@ -11,12 +11,6 @@ public class TrackAudio public ProcessingStatus TranscodeStatus { get; set; } = ProcessingStatus.Pending; - public bool DownloadRequested { get; set; } - - public int RetryCount { get; set; } - - public DateTime? LastRetryAt { get; set; } - [MemberNotNullWhen(true, nameof(FolderName))] public bool IsProcessed => TranscodeStatus == ProcessingStatus.Completed; diff --git a/backend/Core/Musify.Domain/ValueObjects/UploadState.cs b/backend/Core/Musify.Domain/ValueObjects/UploadState.cs deleted file mode 100644 index 729378b..0000000 --- a/backend/Core/Musify.Domain/ValueObjects/UploadState.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Musify.Domain.ValueObjects; - -public enum UploadState -{ - Pending, - Failed, - Successful, -} diff --git a/backend/Core/Musify.Infrastructure/Infrastructure.csproj b/backend/Core/Musify.Infrastructure/Infrastructure.csproj index 306716f..a24d3fc 100644 --- a/backend/Core/Musify.Infrastructure/Infrastructure.csproj +++ b/backend/Core/Musify.Infrastructure/Infrastructure.csproj @@ -14,16 +14,18 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all - + + diff --git a/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs b/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs index c2bc8f0..40cf862 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs @@ -54,20 +54,17 @@ public class UploadIntentExpirationJob( 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) { - 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); - } + database.UploadIntents.RemoveRange(oldExpiredIntents); + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Deleted {Count} old Expired upload intents past retention window", oldExpiredIntents.Count); } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromUrlActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromUrlActivity.cs deleted file mode 100644 index 192e668..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromUrlActivity.cs +++ /dev/null @@ -1,68 +0,0 @@ -using MassTransit; -using Microsoft.Extensions.Logging; -using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Logs; - -namespace Musify.Infrastructure.MassTransit.Activities.Files; - -internal class DownloadFileFromUrlActivity( - IHttpClientFactory httpClientFactory, - ILogger logger) - : IActivity -{ - public const string ExecuteEndpointName = "download-file-from-url"; - - public async Task Execute(ExecuteContext executeContext) - { - var destinationPath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); - ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath)); - - try - { - 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; - } - } - - 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/Pictures/UpdateAlbumPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs index 8d4bf2e..6f9b0a2 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs @@ -57,7 +57,6 @@ internal class UpdateAlbumPictureActivity( 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", @@ -98,7 +97,6 @@ internal class UpdateAlbumPictureActivity( LargeName = compensateContext.Log.PreviousLargePictureKey }; - database.Albums.Update(album); await database.SaveChangesAsync(compensateContext.CancellationToken); return compensateContext.Compensated(); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs index 36233e6..f5ab673 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs @@ -57,7 +57,6 @@ internal class UpdatePlayListPictureActivity( 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", @@ -98,7 +97,6 @@ internal class UpdatePlayListPictureActivity( LargeName = compensateContext.Log.PreviousLargePictureKey }; - database.PlayLists.Update(playList); await database.SaveChangesAsync(compensateContext.CancellationToken); return compensateContext.Compensated(); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs index ab1999a..e209d28 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs @@ -51,7 +51,6 @@ internal class UpdateTrackPictureActivity( 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", @@ -88,7 +87,6 @@ internal class UpdateTrackPictureActivity( track.Pictures.LargeName = compensateContext.Log.PreviousLargePictureKey; track.Pictures.ProcessingStatus = ProcessingStatus.Failed; - database.Tracks.Update(track); await database.SaveChangesAsync(compensateContext.CancellationToken); return compensateContext.Compensated(); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromUrlArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromUrlArguments.cs deleted file mode 100644 index 4784e12..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromUrlArguments.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record DownloadFileFromUrlArguments( - string SourceUrl, - string DestinationFilePathVariable); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs index 678b679..f86a6cc 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs @@ -75,6 +75,5 @@ public static partial class MassTransitDependencyInjection options.AddActivity(); options.AddActivity(); options.AddActivity(); - options.AddActivity(); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromUrlLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromUrlLog.cs deleted file mode 100644 index f063578..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromUrlLog.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record DownloadFileFromUrlLog( - string SourceUrl, - string DestinationFilePath); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs index 76d0a02..c6ddefd 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs @@ -16,7 +16,6 @@ internal static class ActivityNames internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; internal const string DownloadFile = "DownloadFile"; - internal const string DownloadThumbnail = "DownloadThumbnail"; internal const string TranscodeAudio = "TranscodeAudio"; diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index 23a7e94..e2b04b2 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -8,12 +8,11 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.MassTransit.Sagas; using Npgsql; -using AppIDatabase = Musify.Application.Contracts.IDatabase; namespace Musify.Infrastructure.Persistence; public class Database(DatabaseConfiguration configuration) - : DbContext, AppIDatabase + : DbContext, Musify.Application.Contracts.IDatabase { private static readonly AuditableEntityInterceptor AuditInterceptor = new(); @@ -54,9 +53,6 @@ public class Database(DatabaseConfiguration configuration) 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(); @@ -193,8 +189,6 @@ public class Database(DatabaseConfiguration configuration) public DbSet MixItems => Set(); - public DbSet Uploads => Set(); - public DbSet UploadIntents => Set(); public DbSet ListeningHistories => Set(); diff --git a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs index a71b19c..b4e68ee 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs @@ -1,4 +1,3 @@ -using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; using Musify.Infrastructure.Configuration; @@ -21,9 +20,6 @@ public class DatabaseDesignTimeFactory : IDesignTimeDbContextFactory .Get() ?? throw new InvalidOperationException($"{DatabaseConfiguration.SectionName} configuration section not found."); - var optionsBuilder = new DbContextOptionsBuilder(); - optionsBuilder.UseNpgsql(databaseConfiguration.ConnectionString); - return new Database(databaseConfiguration); } } diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924031745_DropUploadTableAndTrackAudioRetryColumns.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924031745_DropUploadTableAndTrackAudioRetryColumns.Designer.cs new file mode 100644 index 0000000..33f55c8 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924031745_DropUploadTableAndTrackAudioRetryColumns.Designer.cs @@ -0,0 +1,1133 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260924031745_DropUploadTableAndTrackAudioRetryColumns")] + partial class DropUploadTableAndTrackAudioRetryColumns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsCounted") + .HasColumnType("boolean"); + + b.Property("LastProgressAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayedSeconds") + .HasColumnType("double precision"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("PlayListId", "TrackId") + .IsUnique(); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId", "TrackId") + .IsUnique(); + + b.ToTable("TrackLikes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowedId") + .HasColumnType("bigint"); + + b.Property("FollowerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FollowedId"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique(); + + b.ToTable("UserFollows"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.HasOne("Musify.Domain.Entities.User", "Followed") + .WithMany() + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("Tags"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924031745_DropUploadTableAndTrackAudioRetryColumns.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924031745_DropUploadTableAndTrackAudioRetryColumns.cs new file mode 100644 index 0000000..6f91664 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924031745_DropUploadTableAndTrackAudioRetryColumns.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class DropUploadTableAndTrackAudioRetryColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Upload"); + + migrationBuilder.DropColumn( + name: "DownloadRequested", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "LastRetryAt", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "RetryCount", + table: "Tracks"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DownloadRequested", + table: "Tracks", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LastRetryAt", + table: "Tracks", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "RetryCount", + table: "Tracks", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "Upload", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Bucket = table.Column(type: "text", nullable: false), + ContentType = table.Column(type: "text", nullable: false), + CreatedTime = table.Column(type: "timestamp with time zone", nullable: false), + Key = table.Column(type: "text", nullable: false), + State = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Upload", x => x.Id); + }); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index c708d49..f543d34 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -511,35 +511,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("TrackTags"); }); - modelBuilder.Entity("Musify.Domain.Entities.Upload", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Bucket") - .IsRequired() - .HasColumnType("text"); - - b.Property("ContentType") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedTime") - .HasColumnType("timestamp with time zone"); - - b.Property("Key") - .IsRequired() - .HasColumnType("text"); - - b.Property("State") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.ToTable("Upload"); - }); - modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => { b.Property("Id") @@ -970,28 +941,16 @@ namespace Musify.Infrastructure.Persistence.Migrations b1.Property("TrackId") .HasColumnType("uuid"); - b1.Property("DownloadRequested") - .HasColumnType("boolean") - .HasColumnName("DownloadRequested"); - b1.Property("FolderName") .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("AudioFolderName"); - b1.Property("LastRetryAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("LastRetryAt"); - b1.Property("OriginalName") .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("OriginalAudioName"); - b1.Property("RetryCount") - .HasColumnType("integer") - .HasColumnName("RetryCount"); - b1.Property("TranscodeStatus") .HasColumnType("integer") .HasColumnName("AudioTranscodeProcessingStatus"); diff --git a/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs b/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs index b073cea..bc13de5 100644 --- a/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs +++ b/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs @@ -75,47 +75,6 @@ public partial class AudioService(ILogger logger, 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; - } - - try - { - logger.LogDebug("Validating audio file {FilePath}", filePath); - - var executionResult = await ExecuteFfmpegAsync( - BuildValidateAudioArguments(filePath), - Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory, - "ValidateAudioFile", - cancellationToken); - - 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); - } - - 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 false; - } - private async Task ExecuteFfmpegWithInputAsync( string arguments, string workingDirectory, @@ -239,9 +198,6 @@ public partial class AudioService(ILogger logger, $" \"{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/ServicesDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index 8149ec8..67e025d 100644 --- a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -39,7 +39,7 @@ public static class ServicesDependencyInjection return serviceDescriptors; } - public static IServiceCollection AddPictureService(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddPictureService(this IServiceCollection serviceDescriptors) { serviceDescriptors.AddScoped(); return serviceDescriptors; diff --git a/backend/Core/Musify.Infrastructure/Services/SingleFlightCache.cs b/backend/Core/Musify.Infrastructure/Services/SingleFlightCache.cs deleted file mode 100644 index 681080c..0000000 --- a/backend/Core/Musify.Infrastructure/Services/SingleFlightCache.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Collections.Concurrent; -using ErrorOr; -using Microsoft.Extensions.Caching.Memory; - -namespace Musify.Infrastructure.Services; - -public sealed class SingleFlightCache(IMemoryCache cache) -{ - private readonly ConcurrentDictionary inFlight = new(); - - public async Task> GetOrCreateAsync( - string key, - TimeSpan duration, - Func>> factory) - { - if (duration <= TimeSpan.Zero) - return await factory(); - - if (cache.TryGetValue(key, out TValue? cached) && cached != null) - return cached; - - 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 _); - } - } -} diff --git a/backend/Core/Musify.Infrastructure/Services/StorageService.cs b/backend/Core/Musify.Infrastructure/Services/StorageService.cs index 69e9855..57fbecc 100644 --- a/backend/Core/Musify.Infrastructure/Services/StorageService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StorageService.cs @@ -4,14 +4,12 @@ using Amazon.S3.Transfer; using Microsoft.Extensions.Logging; using MimeMapping; using Musify.Application.Contracts; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; using Musify.Infrastructure.Configuration; using ObjectMetaData = Musify.Application.Contracts.ObjectMetaData; namespace Musify.Infrastructure.Services; -public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger logger, +public class StorageService(IAmazonS3 amazonS3, ILogger logger, InfrastructureStorageConfiguration storageClientConfiguration) : IStorageService { @@ -41,20 +39,6 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger GetUrlAsync(string bucket, string key, TimeSpan expirationTime, CancellationToken cancellationToken) - { - 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); - } - public async Task GetUploadUrlAsync( string bucket, string key, @@ -129,15 +113,6 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger !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); - try { await trasnsferUtility.UploadAsync( @@ -149,7 +124,6 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger 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/Hosts/Musify.Api/Api.csproj b/backend/Hosts/Musify.Api/Api.csproj index b693556..ed556be 100644 --- a/backend/Hosts/Musify.Api/Api.csproj +++ b/backend/Hosts/Musify.Api/Api.csproj @@ -8,29 +8,12 @@ - - - - - - - - - - - - - - - - - diff --git a/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs b/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs index 9e3c5bc..b6d15fb 100644 --- a/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs +++ b/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs @@ -7,7 +7,6 @@ public sealed class CurrentUser : IBindableFromHttpContext { private CurrentUser(ClaimsPrincipal principal) { - Principal = principal; IsAuthenticated = principal.Identity?.IsAuthenticated ?? false; var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); @@ -19,8 +18,6 @@ public sealed class CurrentUser : IBindableFromHttpContext LastName = principal.FindFirstValue(ClaimTypes.Surname); } - public ClaimsPrincipal Principal { get; } - public bool IsAuthenticated { get; } public long? Id { get; } @@ -33,10 +30,6 @@ public sealed class CurrentUser : IBindableFromHttpContext public long RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid."); - public bool HasClaim(string type) => Principal.HasClaim(c => c.Type == type); - - public string? FindClaim(string type) => Principal.FindFirstValue(type); - public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) => ValueTask.FromResult(new CurrentUser(context.User)); } diff --git a/backend/Hosts/Musify.Api/appsettings.json b/backend/Hosts/Musify.Api/appsettings.json index 4c0c5aa..e771a5e 100644 --- a/backend/Hosts/Musify.Api/appsettings.json +++ b/backend/Hosts/Musify.Api/appsettings.json @@ -116,19 +116,6 @@ "LargePictureHeight": 512 } }, - "AudioTranscoder": { - "TranscodingTimeout": "00:05:00", - "Ffmpeg": { - "ExecutableName": "ffmpeg", - "OutputFileName": "audio.m4a", - "AudioCodec": "aac", - "AudioBitrate": "128k", - "AudioChannels": 2, - "AudioSampleRate": 48000, - "AudioProfile": "aac_low", - "AdditionalArguments": "" - } - }, "UploadIntent": { "UploadUrlExpiresInSeconds": 120, "MaxActiveUploadIntentsPerUser": 5, @@ -141,10 +128,5 @@ "TempRootPrefix": "temp", "TempUploadsRetentionDays": 3, "TempCleanupJobIntervalSeconds": 3600 - }, - "Workers": { - "Routes": { - "TemporaryFilesDirectory": "" - } } } diff --git a/backend/Hosts/Musify.Worker/Program.cs b/backend/Hosts/Musify.Worker/Program.cs index bda423d..92389ba 100644 --- a/backend/Hosts/Musify.Worker/Program.cs +++ b/backend/Hosts/Musify.Worker/Program.cs @@ -23,8 +23,7 @@ services.AddValidatedOptions(configuration, TrackConfigurati services.AddValidatedOptions(configuration, MixConfiguration.SectionName); services.AddStorageService(configuration); services.AddAudioTranscoder(configuration); -services.AddHttpClient(); -services.AddPictureService(configuration); +services.AddPictureService(); services.AddDatabase(configuration); services.AddMassTransitConsumers(configuration); services.AddUploadIntentJobs(configuration); diff --git a/backend/Hosts/Musify.Worker/Worker.csproj b/backend/Hosts/Musify.Worker/Worker.csproj index e264aa3..00dadc0 100644 --- a/backend/Hosts/Musify.Worker/Worker.csproj +++ b/backend/Hosts/Musify.Worker/Worker.csproj @@ -7,17 +7,8 @@ - - - - - - - - - diff --git a/backend/Hosts/Musify.Worker/appsettings.json b/backend/Hosts/Musify.Worker/appsettings.json index 5a93fc4..394b30e 100644 --- a/backend/Hosts/Musify.Worker/appsettings.json +++ b/backend/Hosts/Musify.Worker/appsettings.json @@ -70,9 +70,6 @@ } }, "AudioTranscoder": { - "Routes": { - "WorkingDirectory": "" - }, "TranscodingTimeout": "00:05:00" }, "Workers": { diff --git a/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs b/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs index 981dda9..0336538 100644 --- a/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs +++ b/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs @@ -63,22 +63,4 @@ public sealed class CurrentUserTests Assert.Throws(() => user.RequiredId); } - - [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")); - } - - [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")); - } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs index c891090..37bab0e 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -151,8 +151,6 @@ public sealed class TestDatabase : DbContext, IDatabase public DbSet MixItems => Set(); - public DbSet Uploads => Set(); - public DbSet UploadIntents => Set(); public DbSet ListeningHistories => Set(); diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs deleted file mode 100644 index da41272..0000000 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -using ErrorOr; -using Microsoft.Extensions.Caching.Memory; -using Musify.Infrastructure.Services; -using Xunit; - -namespace Musify.Infrastructure.Tests.Services; - -public sealed class SingleFlightCacheTests -{ - private static SingleFlightCache CreateCache() => new(new MemoryCache(new MemoryCacheOptions())); - - [Fact] - public async Task GetOrCreateAsync_SecondCallWithinDuration_ReturnsCachedValueWithoutCallingFactory() - { - 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); - } - - [Fact] - public async Task GetOrCreateAsync_ConcurrentCallsForTheSameKey_CollapseIntoASingleFactoryCall() - { - 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)); - } - - [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); - - Assert.Equal(2, 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); - - 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 80bbcc1..7065485 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs @@ -3,7 +3,6 @@ using System.Net.Http.Headers; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using Musify.Infrastructure.Configuration; -using Musify.Infrastructure.Persistence; using Musify.Infrastructure.Services; using Musify.Infrastructure.Tests.TestSupport; using Xunit; @@ -13,12 +12,10 @@ namespace Musify.Infrastructure.Tests.Services; [Collection(InfrastructureCollection.Name)] public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime { - private Database database = null!; private StorageService service = null!; public ValueTask InitializeAsync() { - database = fixture.CreateDatabase(); var storageConfiguration = new InfrastructureStorageConfiguration { Address = fixture.S3ServiceUrl, @@ -27,11 +24,11 @@ public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAs ForcePathStyle = true, UseHttp = true, }; - service = new StorageService(database, fixture.CreateS3Client(), NullLogger.Instance, storageConfiguration); + service = new StorageService(fixture.CreateS3Client(), NullLogger.Instance, storageConfiguration); return ValueTask.CompletedTask; } - public ValueTask DisposeAsync() => database.DisposeAsync(); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; [Fact] public async Task UploadThenGetFile_RoundTripsTheSameBytes() @@ -114,21 +111,6 @@ public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAs 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() { diff --git a/deploy/compose.yml b/deploy/compose.yml index c3b5318..9d7d976 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -315,7 +315,6 @@ services: ASPNETCORE_URLS: http://+:5111 StreamGateway__PublicBaseUrl: ${PUBLIC_STREAM_URL} StreamTicket__PrivateKeyPath: /keys/stream_private.pem - Workers__Routes__TemporaryFilesDirectory: /tmp/musify # AUTH_CLIENT_ID is written to the env file by the zitadel-init service. Authentication__MetadataAddress: "${PUBLIC_AUTH_URL}/.well-known/openid-configuration" Authentication__IssuerAddress: ${PUBLIC_AUTH_URL} @@ -344,7 +343,6 @@ services: environment: <<: *backend-env DOTNET_ENVIRONMENT: ${DOTNET_ENVIRONMENT} - AudioTranscoder__Routes__WorkingDirectory: /tmp/musify Workers__Routes__TemporaryFilesDirectory: /tmp/musify restart: unless-stopped diff --git a/docs/architecture.md b/docs/architecture.md index 3af19ac..167b6ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,4 +52,3 @@ See [authentication.md](authentication.md). - **PlayListHasTrack**: the tracks inside a playlist (with `Position`). - **UserHasTrack**: ownership, which user owns which track. - **UploadIntent**: an upload reservation (key, bucket, expected size, status, expiration) used for validation and quota. -- **Upload**: a record of objects uploaded or transferred to storage. diff --git a/docs/development.md b/docs/development.md index 9f9824b..a2d7a4f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -88,7 +88,7 @@ dotnet test backend/Musify.slnx |---|---|---| | `Musify.Domain.Tests` | Value object logic (`TrackAudio`/`TrackPictures`: `IsProcessed`, `IsFailed`, `IsInProgress`). Entities are mostly anemic, with no behavior of their own beyond that. | No | | `Musify.Application.Tests` | The ~45 handlers and services in `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes). | No | -| `Musify.Infrastructure.Tests` | `Database`/migrations, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, `SingleFlightCache`, `PlayListPresetSeeder`. | **Yes** | +| `Musify.Infrastructure.Tests` | `Database`/migrations, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, `PlayListPresetSeeder`. | **Yes** | | `Musify.Api.Tests` | Real HTTP endpoints (routing, auth, `ValidationFilter`, `ErrorOr`→HTTP mapping) via `WebApplicationFactory`, plus `ErrorOrHttpExtensions`/`CurrentUser`/FluentValidation validators in isolation. | **Yes** | | `Musify.StreamingGateway.Tests` | `TicketValidator` (RS256) and `TicketValidationMiddleware` (traversal, prefix limits, ticket extraction). | No | From 4d943e068ef1e4b3e1f87c96fbd046ea7d8ac9a9 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 05:23:40 +0200 Subject: [PATCH 06/21] Unify picture configuration and options registration --- .../Musify.Application/Albums/CreateAlbum.cs | 17 +--- .../Albums/GetAlbumCover.cs | 13 ++- .../Albums/RequestAlbumPictureUpload.cs | 2 +- .../Musify.Application/Albums/UpdateAlbum.cs | 16 +--- .../ApplicationDependencyInjection.cs | 16 ++-- .../Configuration/AlbumConfiguration.cs | 72 +-------------- .../ApplicationStorageConfiguration.cs | 4 +- .../ConfigurationDependencyInjection.cs | 4 + .../Configuration/IConfigurationOptions.cs | 6 ++ .../Configuration/MixConfiguration.cs | 4 +- .../Configuration/Pictures.cs | 90 ++++++++++++++++++ .../Configuration/PlayListConfiguration.cs | 72 +-------------- .../Configuration/PlaybackConfiguration.cs | 4 +- .../StreamGatewayConfiguration.cs | 4 +- .../Configuration/TrackConfiguration.cs | 92 ++++--------------- .../UploadIntentConfiguration.cs | 4 +- .../PlayLists/CreatePlayList.cs | 17 +--- .../PlayLists/GetPlayListCover.cs | 13 ++- .../PlayLists/RequestPlayListPictureUpload.cs | 2 +- .../PlayLists/UpdatePlayList.cs | 16 +--- .../Musify.Application/Shared/ImageSize.cs | 5 + .../Musify.Application/Tracks/CreateTrack.cs | 17 +--- .../Tracks/GetTrackCover.cs | 13 ++- .../Tracks/RequestTrackUploadUrls.cs | 4 +- .../Configuration/AudioConfiguration.cs | 5 +- .../Configuration/DatabaseConfiguration.cs | 5 +- .../InfrastructureStorageConfiguration.cs | 5 +- .../Configuration/MassTransitConfiguration.cs | 5 +- .../OpenTelemetryConfiguration.cs | 5 +- .../StreamTicketConfiguration.cs | 5 +- .../Configuration/WorkerConfiguration.cs | 5 +- .../MassTransitDependencyInjection.Options.cs | 23 ----- .../MassTransitDependencyInjection.cs | 32 +++---- .../Builders/DeleteAlbumRoutingSlipBuilder.cs | 6 +- .../DeletePlayListRoutingSlipBuilder.cs | 6 +- .../Builders/DeleteTrackRoutingSlipBuilder.cs | 6 +- .../OpenTelemetryDependencyInjection.cs | 18 ++-- .../DatabaseDependencyInjection.cs | 18 ++-- .../Services/ServicesDependencyInjection.cs | 79 ++++++---------- .../AuthenticationConfiguration.cs | 5 +- .../AuthenticationExtensions.cs | 10 +- backend/Hosts/Musify.Api/appsettings.json | 42 ++++----- .../Authentication/TicketValidator.cs | 7 +- ...=> StreamTicketValidationConfiguration.cs} | 2 +- .../Middleware/TicketValidationMiddleware.cs | 5 +- .../Hosts/Musify.StreamingGateway/Program.cs | 8 +- backend/Hosts/Musify.Worker/Program.cs | 8 +- backend/Hosts/Musify.Worker/appsettings.json | 28 +++--- .../PicturesConfigurationTests.cs | 50 ++++++++++ .../Authentication/TicketValidatorTests.cs | 5 +- .../TicketValidationMiddlewareTests.cs | 9 +- 51 files changed, 384 insertions(+), 525 deletions(-) create mode 100644 backend/Core/Musify.Application/Configuration/IConfigurationOptions.cs create mode 100644 backend/Core/Musify.Application/Configuration/Pictures.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs rename backend/Hosts/Musify.StreamingGateway/Configuration/{StreamTicketValidationOptions.cs => StreamTicketValidationConfiguration.cs} (90%) create mode 100644 backend/Tests/Musify.Application.Tests/Configuration/PicturesConfigurationTests.cs diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index 39722ca..eac52f3 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -7,7 +7,6 @@ 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; @@ -90,6 +89,7 @@ public class CreateAlbumCommandHandler( { try { + var sizes = albumConfiguration.PicturesSizes.ToImageSizes(albumConfiguration.Routes); await eventBus.PublishAsync( new CreateAlbumResourcesEvent( albumId, @@ -97,18 +97,9 @@ public class CreateAlbumCommandHandler( 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)), + sizes.Small, + sizes.Medium, + sizes.Large), cancellationToken); return new Success(); } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumCover.cs b/backend/Core/Musify.Application/Albums/GetAlbumCover.cs index e4c0ddf..b9ca892 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumCover.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumCover.cs @@ -32,12 +32,14 @@ public class GetAlbumCoverQueryHandler( var pictures = owned.Pictures; var routes = albumConfiguration.Routes; - var (name, key) = request.Size.ToLowerInvariant() switch + var size = PictureSizeParser.Parse(request.Size); + var name = size switch { - "small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)), - "large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)), - _ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName)) + PictureSize.Small => pictures.SmallName, + PictureSize.Large => pictures.LargeName, + _ => pictures.MediumName }; + var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name); // The resized files only exist once processing has finished; until then serve the original. if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) @@ -52,7 +54,4 @@ public class GetAlbumCoverQueryHandler( 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/RequestAlbumPictureUpload.cs b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs index 09c3244..b689b71 100644 --- a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs +++ b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs @@ -44,7 +44,7 @@ public class RequestAlbumPictureUploadCommandHandler( var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; - var tempKey = albumConfiguration.Routes.BuildTempPicturePath( + var tempKey = albumConfiguration.Routes.BuildTempPath( uploadIntentConfiguration.TempRootPrefix, request.UserId, objectName); try diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index f03bfcf..a6c9d7b 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -106,6 +106,7 @@ public class UpdateAlbumCommandHandler( { try { + var sizes = albumConfiguration.PicturesSizes.ToImageSizes(albumConfiguration.Routes); await eventBus.PublishAsync( new UpdateAlbumPictureSourceEvent( albumId, @@ -113,18 +114,9 @@ public class UpdateAlbumCommandHandler( 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)), + sizes.Small, + sizes.Medium, + sizes.Large), cancellationToken); return new Success(); } diff --git a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs index 176157c..b1fb216 100644 --- a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs +++ b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs @@ -13,14 +13,14 @@ public static class ApplicationDependencyInjection services.AddScoped(); services.AddScoped(); - services.AddValidatedOptions(configuration, ApplicationStorageConfiguration.SectionName); - services.AddValidatedOptions(configuration, PlayListConfiguration.SectionName); - services.AddValidatedOptions(configuration, AlbumConfiguration.SectionName); - services.AddValidatedOptions(configuration, TrackConfiguration.SectionName); - services.AddValidatedOptions(configuration, MixConfiguration.SectionName); - services.AddValidatedOptions(configuration, StreamGatewayConfiguration.SectionName); - services.AddValidatedOptions(configuration, UploadIntentConfiguration.SectionName); - services.AddValidatedOptions(configuration, PlaybackConfiguration.SectionName); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); return services; } diff --git a/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs b/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs index a505c99..c139a4d 100644 --- a/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs @@ -1,78 +1,14 @@ using System.ComponentModel.DataAnnotations; -using Musify.Application.Shared; namespace Musify.Application.Configuration; -public class AlbumConfiguration +public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration { - public const string SectionName = "Album"; + public static string SectionName => "Album"; [Required] - public AlbumRoutes Routes { get; set; } = new AlbumRoutes(); + public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" }; [Required] - public AlbumPicturesSizes PicturesSizes { get; set; } = new AlbumPicturesSizes(); -} - -public class AlbumRoutes -{ - [Required] - public string UploadsFolder { get; set; } = "uploads"; - - [Required] - public string ParentFolders { get; set; } = "Albums"; - - [Required] - public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; - - [Required] - public string SmallPicturesFolder { get; set; } = "SmallPictures"; - - [Required] - public string MediumPicturesFolder { get; set; } = "MediumPictures"; - - [Required] - public string LargePicturesFolder { get; set; } = "LargePictures"; - - public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder); - - public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder); - - public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder); - - public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder); - - 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 BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName); - - public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, 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 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 MediumPictureWidth { 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 LargePictureHeight { get; set; } = 512; + public PictureSizes PicturesSizes { get; set; } = new(); } diff --git a/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs b/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs index b402a36..f6fedc4 100644 --- a/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs @@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations; namespace Musify.Application.Configuration; -public class ApplicationStorageConfiguration +public class ApplicationStorageConfiguration : IConfigurationOptions { - public const string SectionName = "ApplicationStorage"; + public static string SectionName => "ApplicationStorage"; [Required] [StringLength(128, MinimumLength = 1)] diff --git a/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs b/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs index 4c341f3..aa7fa1a 100644 --- a/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs +++ b/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs @@ -6,6 +6,10 @@ namespace Musify.Application.Configuration; public static class ConfigurationDependencyInjection { + public static IServiceCollection AddValidatedOptions(this IServiceCollection services, IConfiguration configuration) + where T : class, IConfigurationOptions => + services.AddValidatedOptions(configuration, T.SectionName); + public static IServiceCollection AddValidatedOptions( this IServiceCollection services, IConfiguration configuration, diff --git a/backend/Core/Musify.Application/Configuration/IConfigurationOptions.cs b/backend/Core/Musify.Application/Configuration/IConfigurationOptions.cs new file mode 100644 index 0000000..3864320 --- /dev/null +++ b/backend/Core/Musify.Application/Configuration/IConfigurationOptions.cs @@ -0,0 +1,6 @@ +namespace Musify.Application.Configuration; + +public interface IConfigurationOptions +{ + public static abstract string SectionName { get; } +} diff --git a/backend/Core/Musify.Application/Configuration/MixConfiguration.cs b/backend/Core/Musify.Application/Configuration/MixConfiguration.cs index af33d2c..bb16860 100644 --- a/backend/Core/Musify.Application/Configuration/MixConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/MixConfiguration.cs @@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations; namespace Musify.Application.Configuration; -public class MixConfiguration +public class MixConfiguration : IConfigurationOptions { - public const string SectionName = "Mix"; + public static string SectionName => "Mix"; [Range(4, 100)] public int ItemsPerMix { get; set; } = 12; diff --git a/backend/Core/Musify.Application/Configuration/Pictures.cs b/backend/Core/Musify.Application/Configuration/Pictures.cs new file mode 100644 index 0000000..bed36ff --- /dev/null +++ b/backend/Core/Musify.Application/Configuration/Pictures.cs @@ -0,0 +1,90 @@ +using System.ComponentModel.DataAnnotations; +using Musify.Application.Shared; + +namespace Musify.Application.Configuration; + +public enum PictureSize +{ + Small, + Medium, + Large +} + +public class PictureRoutes +{ + [Required] + public string UploadsFolder { get; set; } = "uploads"; + + [Required] + public string ParentFolder { get; set; } = string.Empty; + + [Required] + public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; + + [Required] + public string SmallPicturesFolder { get; set; } = "SmallPictures"; + + [Required] + public string MediumPicturesFolder { get; set; } = "MediumPictures"; + + [Required] + public string LargePicturesFolder { get; set; } = "LargePictures"; + + public string FolderPath(PictureSize size) => StorageKey.Combine(ParentFolder, size switch + { + PictureSize.Small => SmallPicturesFolder, + PictureSize.Medium => MediumPicturesFolder, + PictureSize.Large => LargePicturesFolder, + _ => throw new ArgumentOutOfRangeException(nameof(size), size, null) + }); + + public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName); + + public string BuildOriginalPicturePath(long userId, string pictureName) => + StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolder, OriginalPicturesFolder, pictureName); + + public string BuildTempPath(string tempRootPrefix, long userId, string objectName) => + StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName); +} + +public class PictureSizes +{ + [Range(1, 1024)] + public int SmallWidth { get; set; } = 128; + + [Range(1, 1024)] + public int SmallHeight { get; set; } = 128; + + [Range(1, 1024)] + public int MediumWidth { get; set; } = 256; + + [Range(1, 1024)] + public int MediumHeight { get; set; } = 256; + + [Range(1, 1024)] + public int LargeWidth { get; set; } = 512; + + [Range(1, 1024)] + public int LargeHeight { get; set; } = 512; + + public ImageSizes ToImageSizes(PictureRoutes routes) => new( + new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight), + new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight), + new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight)); +} + +public interface IPictureOwnerConfiguration +{ + public PictureRoutes Routes { get; } + + public PictureSizes PicturesSizes { get; } +} + +public static class PictureSizeParser +{ + /// Parses the size query value; anything unknown means . + public static PictureSize Parse(string? value) => + Enum.TryParse(value, ignoreCase: true, out var size) && Enum.IsDefined(size) + ? size + : PictureSize.Medium; +} diff --git a/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs b/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs index 10b7051..10385a9 100644 --- a/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs @@ -1,78 +1,14 @@ using System.ComponentModel.DataAnnotations; -using Musify.Application.Shared; namespace Musify.Application.Configuration; -public class PlayListConfiguration +public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration { - public const string SectionName = "PlayList"; + public static string SectionName => "PlayList"; [Required] - public PlayListRoutes Routes { get; set; } = new PlayListRoutes(); + public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" }; [Required] - public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes(); -} - -public class PlayListRoutes -{ - [Required] - public string UploadsFolder { get; set; } = "uploads"; - - [Required] - public string ParentFolders { get; set; } = "PlayLists"; - - [Required] - public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; - - [Required] - public string SmallPicturesFolder { get; set; } = "SmallPictures"; - - [Required] - public string MediumPicturesFolder { get; set; } = "MediumPictures"; - - [Required] - public string LargePicturesFolder { get; set; } = "LargePictures"; - - public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder); - - public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder); - - public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder); - - public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder); - - 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 BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName); - - public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, 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 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 MediumPictureWidth { 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 LargePictureHeight { get; set; } = 512; + public PictureSizes PicturesSizes { get; set; } = new(); } diff --git a/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs b/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs index 6a275bb..2fac12b 100644 --- a/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs @@ -3,9 +3,9 @@ using System.ComponentModel.DataAnnotations; namespace Musify.Application.Configuration; -public sealed class PlaybackConfiguration +public sealed class PlaybackConfiguration : IConfigurationOptions { - public const string SectionName = "Playback"; + public static string SectionName => "Playback"; public bool AllowAnonymousListening { get; set; } diff --git a/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs b/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs index 03a3dcb..3c9ecb8 100644 --- a/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs @@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations; namespace Musify.Application.Configuration; -public class StreamGatewayConfiguration +public class StreamGatewayConfiguration : IConfigurationOptions { - public const string SectionName = "StreamGateway"; + public static string SectionName => "StreamGateway"; [Url] [Required] diff --git a/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs b/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs index 1e74b48..b391286 100644 --- a/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs @@ -3,36 +3,25 @@ using Musify.Application.Shared; namespace Musify.Application.Configuration; -public class TrackConfiguration +public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration { - public const string SectionName = "Track"; + public static string SectionName => "Track"; [Required] - public TrackRoutes Routes { get; set; } = new TrackRoutes(); + public TrackRoutes Routes { get; set; } = new(); [Required] - public TrackPicturesSizes PicturesSizes { get; set; } = new TrackPicturesSizes(); + public PictureSizes PicturesSizes { get; set; } = new(); + + PictureRoutes IPictureOwnerConfiguration.Routes => Routes; } -public class TrackRoutes +public class TrackRoutes : PictureRoutes { - [Required] - public string UploadsFolder { get; set; } = "uploads"; - - [Required] - public string ParentFolder { get; set; } = "Tracks"; - - [Required] - public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; - - [Required] - public string SmallPicturesFolder { get; set; } = "SmallPictures"; - - [Required] - public string MediumPicturesFolder { get; set; } = "MediumPictures"; - - [Required] - public string LargePicturesFolder { get; set; } = "LargePictures"; + public TrackRoutes() + { + ParentFolder = "Tracks"; + } [Required] public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp"; @@ -50,13 +39,7 @@ public class TrackRoutes public string OriginalAudiosFolder { get; set; } = "OriginalAudios"; [Required] - public string ProcessedAudioFolder { get; set; } = "ProcessedAudios"; - - public string SmallPicturesPath => StorageKey.Combine(ParentFolder, SmallPicturesFolder); - - public string MediumPicturesPath => StorageKey.Combine(ParentFolder, MediumPicturesFolder); - - public string LargePicturesPath => StorageKey.Combine(ParentFolder, LargePicturesFolder); + public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios"; public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture); @@ -64,52 +47,9 @@ public class TrackRoutes public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture); - public string OriginalPicturesPath => StorageKey.Combine(ParentFolder, OriginalPicturesFolder); + public string BuildOriginalAudioPath(long userId, string audioName) => + StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolder, OriginalAudiosFolder, audioName); - public string OriginalAudiosPath => StorageKey.Combine(ParentFolder, OriginalAudiosFolder); - - public string ProcessedAudiosPath => StorageKey.Combine(ParentFolder, ProcessedAudioFolder); - - 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 BuildSmallPicturePath(string pictureName) => StorageKey.Combine(SmallPicturesPath, pictureName); - - public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(MediumPicturesPath, pictureName); - - public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(LargePicturesPath, pictureName); - - 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 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 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; - - [Range(1, 1024)] - public int SmallPictureHeight { get; set; } = 128; - - [Range(1, 1024)] - public int MediumPictureWidth { 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 LargePictureHeight { get; set; } = 512; + public string BuildProcessedAudioPath(string folderName) => + StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName); } diff --git a/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs b/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs index eb05dee..3b72185 100644 --- a/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs @@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations; namespace Musify.Application.Configuration; -public class UploadIntentConfiguration +public class UploadIntentConfiguration : IConfigurationOptions { - public const string SectionName = "UploadIntent"; + public static string SectionName => "UploadIntent"; [Range(10, 3600)] public int UploadUrlExpiresInSeconds { get; set; } = 120; diff --git a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index ba015de..d78024b 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -7,7 +7,6 @@ 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; @@ -108,6 +107,7 @@ public class CreatePlayListCommandHandler( { try { + var sizes = playListConfiguration.PicturesSizes.ToImageSizes(playListConfiguration.Routes); await eventBus.PublishAsync( new CreatePlayListResourcesEvent( playListId, @@ -115,18 +115,9 @@ public class CreatePlayListCommandHandler( 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)), + sizes.Small, + sizes.Medium, + sizes.Large), cancellationToken); return new Success(); } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs index 7660459..e456e7d 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs @@ -32,12 +32,14 @@ public class GetPlayListCoverQueryHandler( var pictures = owned.Pictures; var routes = playListConfiguration.Routes; - var (name, key) = request.Size.ToLowerInvariant() switch + var size = PictureSizeParser.Parse(request.Size); + var name = size switch { - "small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)), - "large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)), - _ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName)) + PictureSize.Small => pictures.SmallName, + PictureSize.Large => pictures.LargeName, + _ => pictures.MediumName }; + var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name); // The resized files only exist once processing has finished; until then serve the original. if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) @@ -52,7 +54,4 @@ public class GetPlayListCoverQueryHandler( 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/RequestPlayListPictureUpload.cs b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs index 38c3a0f..6603940 100644 --- a/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs +++ b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs @@ -44,7 +44,7 @@ public class RequestPlayListPictureUploadCommandHandler( var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; - var tempKey = playListConfiguration.Routes.BuildTempPicturePath( + var tempKey = playListConfiguration.Routes.BuildTempPath( uploadIntentConfiguration.TempRootPrefix, request.UserId, objectName); try diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index 9516d44..e4c395a 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -111,6 +111,7 @@ public class UpdatePlayListCommandHandler( { try { + var sizes = playListConfiguration.PicturesSizes.ToImageSizes(playListConfiguration.Routes); await eventBus.PublishAsync( new UpdatePlayListPictureSourceEvent( playListId, @@ -118,18 +119,9 @@ public class UpdatePlayListCommandHandler( 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)), + sizes.Small, + sizes.Medium, + sizes.Large), cancellationToken); return new Success(); } diff --git a/backend/Core/Musify.Application/Shared/ImageSize.cs b/backend/Core/Musify.Application/Shared/ImageSize.cs index ba33f19..0a9ebb4 100644 --- a/backend/Core/Musify.Application/Shared/ImageSize.cs +++ b/backend/Core/Musify.Application/Shared/ImageSize.cs @@ -4,3 +4,8 @@ public record ImageSize( string SavePath, int Width, int Height); + +public record ImageSizes( + ImageSize Small, + ImageSize Medium, + ImageSize Large); diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index d16e848..1f2cfc1 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -6,7 +6,6 @@ using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Events; using Musify.Application.Services; -using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; @@ -117,6 +116,7 @@ public class CreateTrackCommandHandler( try { + var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes); await eventBus.PublishAsync( new CreateTrackResourcesEvent( trackEntity.Id, @@ -128,18 +128,9 @@ public class CreateTrackCommandHandler( 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)), + sizes.Small, + sizes.Medium, + sizes.Large), cancellationToken); } catch (Exception exception) diff --git a/backend/Core/Musify.Application/Tracks/GetTrackCover.cs b/backend/Core/Musify.Application/Tracks/GetTrackCover.cs index 39ab256..3e8f602 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackCover.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackCover.cs @@ -28,12 +28,14 @@ public class GetTrackCoverQueryHandler( return Error.NotFound(); var routes = trackConfiguration.Routes; - var (name, key) = request.Size.ToLowerInvariant() switch + var size = PictureSizeParser.Parse(request.Size); + var name = size switch { - "small" => (track.SmallName, BuildKey(routes.BuildSmallPicturePath, track.SmallName)), - "large" => (track.LargeName, BuildKey(routes.BuildLargePicturePath, track.LargeName)), - _ => (track.MediumName, BuildKey(routes.BuildMediumPicturePath, track.MediumName)) + PictureSize.Small => track.SmallName, + PictureSize.Large => track.LargeName, + _ => track.MediumName }; + var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name); if (string.IsNullOrEmpty(name) || key == null) return Error.NotFound(); @@ -46,7 +48,4 @@ public class GetTrackCoverQueryHandler( return new TrackCoverLocation(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/Tracks/RequestTrackUploadUrls.cs b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs index 4d2fa1d..143dce0 100644 --- a/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs +++ b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs @@ -50,9 +50,9 @@ public class RequestTrackUploadUrlsCommandHandler( var pictureObjectName = $"{Guid.NewGuid()}.{request.PictureFileType.TrimStart('.').ToLowerInvariant()}"; var audioObjectName = $"{Guid.NewGuid()}.{request.AudioFileType.TrimStart('.').ToLowerInvariant()}"; - var tempPictureKey = trackConfiguration.Routes.BuildTempPicturePath( + var tempPictureKey = trackConfiguration.Routes.BuildTempPath( uploadIntentConfiguration.TempRootPrefix, request.UserId, pictureObjectName); - var tempAudioKey = trackConfiguration.Routes.BuildTempAudioPath( + var tempAudioKey = trackConfiguration.Routes.BuildTempPath( uploadIntentConfiguration.TempRootPrefix, request.UserId, audioObjectName); try diff --git a/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs index b4573a6..e2abd10 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; +using Musify.Application.Configuration; namespace Musify.Infrastructure.Configuration; -public class AudioConfiguration +public class AudioConfiguration : IConfigurationOptions { - public const string SectionName = "AudioTranscoder"; + public static string SectionName => "AudioTranscoder"; [Required] public TimeSpan TranscodingTimeout { get; set; } = TimeSpan.FromMinutes(5); diff --git a/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs index 63a5eca..6df8c20 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; +using Musify.Application.Configuration; namespace Musify.Infrastructure.Configuration; -public class DatabaseConfiguration +public class DatabaseConfiguration : IConfigurationOptions { - public const string SectionName = "Database"; + public static string SectionName => "Database"; [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 948c513..45738bf 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; +using Musify.Application.Configuration; namespace Musify.Infrastructure.Configuration; -public class InfrastructureStorageConfiguration +public class InfrastructureStorageConfiguration : IConfigurationOptions { - public const string SectionName = "InfrastructureStorage"; + public static string SectionName => "InfrastructureStorage"; [Url] [Required] diff --git a/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs index 685492d..8472cbd 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; +using Musify.Application.Configuration; namespace Musify.Infrastructure.Configuration; -public class MassTransitConfiguration +public class MassTransitConfiguration : IConfigurationOptions { - public const string SectionName = "MassTransit"; + public static string SectionName => "MassTransit"; [Required] public required string Address { get; set; } diff --git a/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs index 5c7d074..e12adcd 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; +using Musify.Application.Configuration; namespace Musify.Infrastructure.Configuration; -public class OpenTelemetryConfiguration +public class OpenTelemetryConfiguration : IConfigurationOptions { - public const string SectionName = "OpenTelemetry"; + public static string SectionName => "OpenTelemetry"; [Url] [Required] diff --git a/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs index 9097609..26c9130 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; +using Musify.Application.Configuration; namespace Musify.Infrastructure.Configuration; -public class StreamTicketConfiguration +public class StreamTicketConfiguration : IConfigurationOptions { - public const string SectionName = "StreamTicket"; + public static string SectionName => "StreamTicket"; [Required] public required string PrivateKeyPath { get; set; } diff --git a/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs index 4cc48ed..3af6ef4 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; +using Musify.Application.Configuration; namespace Musify.Infrastructure.Configuration; -public class WorkerConfiguration +public class WorkerConfiguration : IConfigurationOptions { - public const string SectionName = "Workers"; + public static string SectionName => "Workers"; [Required] public WorkerRoutes Routes { get; set; } = new WorkerRoutes(); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs deleted file mode 100644 index 82ae90f..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace Musify.Infrastructure.MassTransit; - -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.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs index 1e6c938..e125e39 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs @@ -5,20 +5,18 @@ using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.MassTransit.Sagas; using Musify.Infrastructure.Persistence; +using Musify.Application.Configuration; namespace Musify.Infrastructure.MassTransit; public static partial class MassTransitDependencyInjection { - public static IServiceCollection AddMassTransitClient(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddMassTransitClient(this IServiceCollection services, IConfiguration configuration) { - RegisterValidatedOptions( - serviceDescriptors, - configuration, - MassTransitConfiguration.SectionName); + services.AddValidatedOptions(configuration); - serviceDescriptors.AddScoped(); - serviceDescriptors.AddMassTransit(options => + services.AddScoped(); + services.AddMassTransit(options => { options.AddEntityFrameworkOutbox(outbox => { @@ -35,24 +33,18 @@ public static partial class MassTransitDependencyInjection busRegistrationContext.GetRequiredService())); }); - return serviceDescriptors; + return services; } - public static IServiceCollection AddMassTransitConsumers(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddMassTransitConsumers(this IServiceCollection services, IConfiguration configuration) { - RegisterValidatedOptions( - serviceDescriptors, - configuration, - MassTransitConfiguration.SectionName); + services.AddValidatedOptions(configuration); - RegisterValidatedOptions( - serviceDescriptors, - configuration, - WorkerConfiguration.SectionName); + services.AddValidatedOptions(configuration); - RegisterRoutingSlipBuilders(serviceDescriptors); + RegisterRoutingSlipBuilders(services); - serviceDescriptors.AddMassTransit(options => + services.AddMassTransit(options => { options.AddEntityFrameworkOutbox(outbox => { @@ -107,7 +99,7 @@ public static partial class MassTransitDependencyInjection }); }); - return serviceDescriptors; + return services; } private static void UseStandardRetry(IReceiveEndpointConfigurator endpointConfigurator) => diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteAlbumRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteAlbumRoutingSlipBuilder.cs index 1618709..95011c6 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteAlbumRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteAlbumRoutingSlipBuilder.cs @@ -59,7 +59,7 @@ public class DeleteAlbumRoutingSlipBuilder( EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - routes.BuildSmallPicturePath(smallName))); + routes.BuildPicturePath(PictureSize.Small, smallName))); } if (album.Pictures.MediumName is { } mediumName) @@ -69,7 +69,7 @@ public class DeleteAlbumRoutingSlipBuilder( EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - routes.BuildMediumPicturePath(mediumName))); + routes.BuildPicturePath(PictureSize.Medium, mediumName))); } if (album.Pictures.LargeName is { } largeName) @@ -79,7 +79,7 @@ public class DeleteAlbumRoutingSlipBuilder( EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - routes.BuildLargePicturePath(largeName))); + routes.BuildPicturePath(PictureSize.Large, largeName))); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs index 306a11e..c05a4f0 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs @@ -59,7 +59,7 @@ public class DeletePlayListRoutingSlipBuilder( EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - routes.BuildSmallPicturePath(smallName))); + routes.BuildPicturePath(PictureSize.Small, smallName))); } if (playList.Pictures.MediumName is { } mediumName) @@ -69,7 +69,7 @@ public class DeletePlayListRoutingSlipBuilder( EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - routes.BuildMediumPicturePath(mediumName))); + routes.BuildPicturePath(PictureSize.Medium, mediumName))); } if (playList.Pictures.LargeName is { } largeName) @@ -79,7 +79,7 @@ public class DeletePlayListRoutingSlipBuilder( EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - routes.BuildLargePicturePath(largeName))); + routes.BuildPicturePath(PictureSize.Large, largeName))); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs index a6da70c..bdd7062 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs @@ -58,21 +58,21 @@ public class DeleteTrackRoutingSlipBuilder( EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - trackConfiguration.Routes.BuildSmallPicturePath(track.Pictures.SmallName))); + trackConfiguration.Routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName))); routingSlipBuilder.AddActivity( ActivityNames.RemoveTrackMediumPicture, EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - trackConfiguration.Routes.BuildMediumPicturePath(track.Pictures.MediumName))); + trackConfiguration.Routes.BuildPicturePath(PictureSize.Medium, track.Pictures.MediumName))); routingSlipBuilder.AddActivity( ActivityNames.RemoveTrackLargePicture, EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - trackConfiguration.Routes.BuildLargePicturePath(track.Pictures.LargeName))); + trackConfiguration.Routes.BuildPicturePath(PictureSize.Large, track.Pictures.LargeName))); } if (!string.IsNullOrEmpty(track.Audio.OriginalName)) diff --git a/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs index ca6adcc..83a513f 100644 --- a/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs @@ -1,35 +1,29 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using Musify.Infrastructure.Configuration; using OpenTelemetry.Exporter; using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; +using Musify.Application.Configuration; namespace Musify.Infrastructure.Observability; public static class OpenTelemetryDependencyInjection { - public static IServiceCollection AddObservability(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddObservability(this IServiceCollection services, IConfiguration configuration) { - serviceDescriptors - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(OpenTelemetryConfiguration.SectionName)) - .ValidateDataAnnotations(); + services.AddValidatedOptions(configuration); - serviceDescriptors.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); - - serviceDescriptors + services .AddOptions() .Configure((exporterOptions, openTelemetryConfiguration) => { exporterOptions.Endpoint = new Uri(openTelemetryConfiguration.OtlpEndpoint); }); - serviceDescriptors + services .AddOpenTelemetry() .ConfigureResource(resource => { @@ -54,6 +48,6 @@ public static class OpenTelemetryDependencyInjection logging.AddOtlpExporter() ); - return serviceDescriptors; + return services; } } diff --git a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs index 6e213f2..c21b070 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs @@ -1,27 +1,21 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; +using Musify.Application.Configuration; namespace Musify.Infrastructure.Persistence; public static class DatabaseDependencyInjection { - public static IServiceCollection AddDatabase(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration) { - serviceDescriptors - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(DatabaseConfiguration.SectionName)) - .ValidateDataAnnotations(); + services.AddValidatedOptions(configuration); - serviceDescriptors.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); + services.AddDbContext(); - serviceDescriptors.AddDbContext(); + services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); - serviceDescriptors.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); - - return serviceDescriptors; + return services; } } diff --git a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index 67e025d..e1ce0c5 100644 --- a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -3,7 +3,6 @@ using Hangfire; using Hangfire.PostgreSql; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; @@ -13,17 +12,11 @@ namespace Musify.Infrastructure.Services; public static class ServicesDependencyInjection { - public static IServiceCollection AddStorageService(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddStorageService(this IServiceCollection services, IConfiguration configuration) { - serviceDescriptors - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(InfrastructureStorageConfiguration.SectionName)) - .ValidateDataAnnotations(); + services.AddValidatedOptions(configuration); - serviceDescriptors.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); - - serviceDescriptors.AddSingleton(serviceProvider => + services.AddSingleton(serviceProvider => { var configuration = serviceProvider.GetRequiredService(); var s3Configuration = new AmazonS3Config @@ -35,74 +28,62 @@ public static class ServicesDependencyInjection return new AmazonS3Client(configuration.AccessKey, configuration.SecretAccessKey, s3Configuration); }); - serviceDescriptors.AddScoped(); - return serviceDescriptors; + services.AddScoped(); + return services; } - public static IServiceCollection AddPictureService(this IServiceCollection serviceDescriptors) + public static IServiceCollection AddPictureService(this IServiceCollection services) { - serviceDescriptors.AddScoped(); - return serviceDescriptors; + services.AddScoped(); + return services; } - public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection services, IConfiguration configuration) { - serviceDescriptors.AddValidatedOptions(configuration, UploadIntentConfiguration.SectionName); + services.AddValidatedOptions(configuration); - return serviceDescriptors; + return services; } - public static IServiceCollection AddUploadIntentJobs(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddUploadIntentJobs(this IServiceCollection services, IConfiguration configuration) { - serviceDescriptors.AddUploadIntentConfiguration(configuration); + services.AddUploadIntentConfiguration(configuration); - serviceDescriptors.AddHostedService(); - serviceDescriptors.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); - return serviceDescriptors; + return services; } - public static IServiceCollection AddDailyMixGenerationJob(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddDailyMixGenerationJob(this IServiceCollection services, IConfiguration configuration) { - serviceDescriptors.AddHangfire((serviceProvider, hangfireConfiguration) => + services.AddHangfire((serviceProvider, hangfireConfiguration) => { var databaseConfiguration = serviceProvider.GetRequiredService(); hangfireConfiguration.UsePostgreSqlStorage(options => options.UseNpgsqlConnection(databaseConfiguration.ConnectionString)); }); - serviceDescriptors.AddHangfireServer(); - serviceDescriptors.AddScoped(); - serviceDescriptors.AddScoped(); + services.AddHangfireServer(); + services.AddScoped(); + services.AddScoped(); - return serviceDescriptors; + return services; } - public static IServiceCollection AddStreamTicketService(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddStreamTicketService(this IServiceCollection services, IConfiguration configuration) { - serviceDescriptors - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(StreamTicketConfiguration.SectionName)) - .ValidateDataAnnotations(); + services.AddValidatedOptions(configuration); - serviceDescriptors.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); - - serviceDescriptors.AddSingleton(); - return serviceDescriptors; + services.AddSingleton(); + return services; } - public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddAudioTranscoder(this IServiceCollection services, IConfiguration configuration) { - serviceDescriptors - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(AudioConfiguration.SectionName)) - .ValidateDataAnnotations(); + services.AddValidatedOptions(configuration); - serviceDescriptors.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); - - serviceDescriptors.AddScoped(); - return serviceDescriptors; + services.AddScoped(); + return services; } } diff --git a/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs b/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs index 5646260..f7196c1 100644 --- a/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs +++ b/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; +using Musify.Application.Configuration; namespace Musify.Api.Authentication; -public sealed class AuthenticationConfiguration +public sealed class AuthenticationConfiguration : IConfigurationOptions { - public const string SectionName = "Authentication"; + public static string SectionName => "Authentication"; [Required] [Url] diff --git a/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs b/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs index 45a31b9..6052f50 100644 --- a/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs +++ b/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs @@ -1,5 +1,5 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.Options; +using Musify.Application.Configuration; namespace Musify.Api.Authentication; @@ -9,13 +9,7 @@ public static class AuthenticationExtensions this IServiceCollection services, IConfiguration configuration) { - services - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(AuthenticationConfiguration.SectionName)) - .ValidateDataAnnotations(); - - services.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); + services.AddValidatedOptions(configuration); services.AddMemoryCache(); services.AddHttpClient(); diff --git a/backend/Hosts/Musify.Api/appsettings.json b/backend/Hosts/Musify.Api/appsettings.json index e771a5e..c981d4a 100644 --- a/backend/Hosts/Musify.Api/appsettings.json +++ b/backend/Hosts/Musify.Api/appsettings.json @@ -59,37 +59,37 @@ "PlayList": { "Routes": { "UploadsFolder": "uploads", - "ParentFolders": "PlayLists", + "ParentFolder": "PlayLists", "OriginalPicturesFolder": "OriginalPictures", "SmallPicturesFolder": "SmallPictures", "MediumPicturesFolder": "MediumPictures", "LargePicturesFolder": "LargePictures" }, "PicturesSizes": { - "SmallPictureWidth": 128, - "SmallPictureHeight": 128, - "MediumPictureWidth": 256, - "MediumPictureHeight": 256, - "LargePictureWidth": 512, - "LargePictureHeight": 512 + "SmallWidth": 128, + "SmallHeight": 128, + "MediumWidth": 256, + "MediumHeight": 256, + "LargeWidth": 512, + "LargeHeight": 512 } }, "Album": { "Routes": { "UploadsFolder": "uploads", - "ParentFolders": "Albums", + "ParentFolder": "Albums", "OriginalPicturesFolder": "OriginalPictures", "SmallPicturesFolder": "SmallPictures", "MediumPicturesFolder": "MediumPictures", "LargePicturesFolder": "LargePictures" }, "PicturesSizes": { - "SmallPictureWidth": 128, - "SmallPictureHeight": 128, - "MediumPictureWidth": 256, - "MediumPictureHeight": 256, - "LargePictureWidth": 512, - "LargePictureHeight": 512 + "SmallWidth": 128, + "SmallHeight": 128, + "MediumWidth": 256, + "MediumHeight": 256, + "LargeWidth": 512, + "LargeHeight": 512 } }, "Track": { @@ -105,15 +105,15 @@ "PresetMediumPicture": "PresetMediumPicture.webp", "PresetLargePicture": "PresetLargePicture.webp", "OriginalAudiosFolder": "OriginalAudios", - "ProcessedAudioFolder": "ProcessedAudios" + "ProcessedAudiosFolder": "ProcessedAudios" }, "PicturesSizes": { - "SmallPictureWidth": 128, - "SmallPictureHeight": 128, - "MediumPictureWidth": 256, - "MediumPictureHeight": 256, - "LargePictureWidth": 512, - "LargePictureHeight": 512 + "SmallWidth": 128, + "SmallHeight": 128, + "MediumWidth": 256, + "MediumHeight": 256, + "LargeWidth": 512, + "LargeHeight": 512 } }, "UploadIntent": { diff --git a/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs b/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs index b2d3a59..5782c4b 100644 --- a/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs +++ b/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs @@ -1,5 +1,4 @@ using System.Security.Cryptography; -using Microsoft.Extensions.Options; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using Musify.StreamingGateway.Configuration; @@ -15,14 +14,14 @@ public sealed record TicketValidationResult(string Prefix, long? MaxBytes) public sealed class TicketValidator : IDisposable { - private readonly StreamTicketValidationOptions _options; + private readonly StreamTicketValidationConfiguration _options; private readonly RSA _rsa; private readonly TokenValidationParameters _validationParameters; private readonly JsonWebTokenHandler _handler = new(); - public TicketValidator(IOptions options) + public TicketValidator(StreamTicketValidationConfiguration options) { - _options = options.Value; + _options = options; _rsa = RSA.Create(); _rsa.ImportFromPem(File.ReadAllText(_options.PublicKeyPath)); diff --git a/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationOptions.cs b/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationConfiguration.cs similarity index 90% rename from backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationOptions.cs rename to backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationConfiguration.cs index 8f21c17..3d9a0ff 100644 --- a/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationOptions.cs +++ b/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationConfiguration.cs @@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations; namespace Musify.StreamingGateway.Configuration; -public sealed class StreamTicketValidationOptions +public sealed class StreamTicketValidationConfiguration { public const string SectionName = "StreamTicket"; diff --git a/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs b/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs index 89da5f8..3aae1f1 100644 --- a/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs +++ b/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs @@ -1,4 +1,3 @@ -using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using Musify.StreamingGateway.Authentication; @@ -9,11 +8,9 @@ namespace Musify.StreamingGateway.Middleware; public sealed class TicketValidationMiddleware( RequestDelegate next, TicketValidator ticketValidator, - IOptions options, + StreamTicketValidationConfiguration options, ILogger logger) { - private readonly StreamTicketValidationOptions options = options.Value; - public async Task InvokeAsync(HttpContext context) { var path = context.Request.Path; diff --git a/backend/Hosts/Musify.StreamingGateway/Program.cs b/backend/Hosts/Musify.StreamingGateway/Program.cs index 464f784..2997e5d 100644 --- a/backend/Hosts/Musify.StreamingGateway/Program.cs +++ b/backend/Hosts/Musify.StreamingGateway/Program.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Options; using Musify.StreamingGateway.Authentication; using Musify.StreamingGateway.Configuration; using Musify.StreamingGateway.Middleware; @@ -5,11 +6,14 @@ using Musify.StreamingGateway.Middleware; var builder = WebApplication.CreateBuilder(); builder.Services - .AddOptions() - .Bind(builder.Configuration.GetRequiredSection(StreamTicketValidationOptions.SectionName)) + .AddOptions() + .Bind(builder.Configuration.GetRequiredSection(StreamTicketValidationConfiguration.SectionName)) .ValidateDataAnnotations() .ValidateOnStart(); +builder.Services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + builder.Services.AddSingleton(); const string corsPolicy = "media-clients"; diff --git a/backend/Hosts/Musify.Worker/Program.cs b/backend/Hosts/Musify.Worker/Program.cs index 92389ba..30947c4 100644 --- a/backend/Hosts/Musify.Worker/Program.cs +++ b/backend/Hosts/Musify.Worker/Program.cs @@ -17,10 +17,10 @@ var configuration = builder.Configuration; var services = builder.Services; -services.AddValidatedOptions(configuration, ApplicationStorageConfiguration.SectionName); -services.AddValidatedOptions(configuration, PlayListConfiguration.SectionName); -services.AddValidatedOptions(configuration, TrackConfiguration.SectionName); -services.AddValidatedOptions(configuration, MixConfiguration.SectionName); +services.AddValidatedOptions(configuration); +services.AddValidatedOptions(configuration); +services.AddValidatedOptions(configuration); +services.AddValidatedOptions(configuration); services.AddStorageService(configuration); services.AddAudioTranscoder(configuration); services.AddPictureService(); diff --git a/backend/Hosts/Musify.Worker/appsettings.json b/backend/Hosts/Musify.Worker/appsettings.json index 394b30e..b7f7360 100644 --- a/backend/Hosts/Musify.Worker/appsettings.json +++ b/backend/Hosts/Musify.Worker/appsettings.json @@ -31,19 +31,19 @@ }, "PlayList": { "Routes": { - "ParentFolders": "PlayLists", + "ParentFolder": "PlayLists", "OriginalPicturesFolder": "OriginalPictures", "SmallPicturesFolder": "SmallPictures", "MediumPicturesFolder": "MediumPictures", "LargePicturesFolder": "LargePictures" }, "PicturesSizes": { - "SmallPictureWidth": 128, - "SmallPictureHeight": 128, - "MediumPictureWidth": 256, - "MediumPictureHeight": 256, - "LargePictureWidth": 512, - "LargePictureHeight": 512 + "SmallWidth": 128, + "SmallHeight": 128, + "MediumWidth": 256, + "MediumHeight": 256, + "LargeWidth": 512, + "LargeHeight": 512 } }, "Track": { @@ -58,15 +58,15 @@ "PresetMediumPicture": "PresetMediumPicture.webp", "PresetLargePicture": "PresetLargePicture.webp", "OriginalAudiosFolder": "OriginalAudios", - "ProcessedAudioFolder": "ProcessedAudios" + "ProcessedAudiosFolder": "ProcessedAudios" }, "PicturesSizes": { - "SmallPictureWidth": 128, - "SmallPictureHeight": 128, - "MediumPictureWidth": 256, - "MediumPictureHeight": 256, - "LargePictureWidth": 512, - "LargePictureHeight": 512 + "SmallWidth": 128, + "SmallHeight": 128, + "MediumWidth": 256, + "MediumHeight": 256, + "LargeWidth": 512, + "LargeHeight": 512 } }, "AudioTranscoder": { diff --git a/backend/Tests/Musify.Application.Tests/Configuration/PicturesConfigurationTests.cs b/backend/Tests/Musify.Application.Tests/Configuration/PicturesConfigurationTests.cs new file mode 100644 index 0000000..d2d0603 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Configuration/PicturesConfigurationTests.cs @@ -0,0 +1,50 @@ +using Musify.Application.Configuration; +using Xunit; + +namespace Musify.Application.Tests.Configuration; + +public sealed class PicturesConfigurationTests +{ + [Fact] + public void AlbumRoutes_BuildTheSameKeysAsBefore() + { + var routes = new AlbumConfiguration().Routes; + + Assert.Equal("Albums/SmallPictures/a.webp", routes.BuildPicturePath(PictureSize.Small, "a.webp")); + Assert.Equal("Albums/MediumPictures/a.webp", routes.BuildPicturePath(PictureSize.Medium, "a.webp")); + Assert.Equal("Albums/LargePictures/a.webp", routes.BuildPicturePath(PictureSize.Large, "a.webp")); + Assert.Equal("uploads/7/Albums/OriginalPictures/a.webp", routes.BuildOriginalPicturePath(7, "a.webp")); + Assert.Equal("temp/7/Albums/obj.webp", routes.BuildTempPath("temp", 7, "obj.webp")); + } + + [Fact] + public void TrackRoutes_KeepTheTrackFolderLayout() + { + var routes = new TrackConfiguration().Routes; + + Assert.Equal("Tracks/ProcessedAudios/folder", routes.BuildProcessedAudioPath("folder")); + Assert.Equal("uploads/7/Tracks/OriginalAudios/song.mp3", routes.BuildOriginalAudioPath(7, "song.mp3")); + Assert.Equal("Tracks/PresetSmallPicture.webp", routes.PresetSmallPicturePath); + } + + [Fact] + public void ToImageSizes_UsesTheRoutesFolders() + { + var configuration = new PlayListConfiguration(); + + var sizes = configuration.PicturesSizes.ToImageSizes(configuration.Routes); + + Assert.Equal("PlayLists/SmallPictures", sizes.Small.SavePath); + Assert.Equal(128, sizes.Small.Width); + Assert.Equal(512, sizes.Large.Width); + } + + [Theory] + [InlineData("small", PictureSize.Small)] + [InlineData("LARGE", PictureSize.Large)] + [InlineData("medium", PictureSize.Medium)] + [InlineData("whatever", PictureSize.Medium)] + [InlineData(null, PictureSize.Medium)] + public void PictureSizeParser_DefaultsToMedium(string? value, PictureSize expected) => + Assert.Equal(expected, PictureSizeParser.Parse(value)); +} diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs index 18f3946..801e9b6 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs @@ -1,5 +1,4 @@ using System.Security.Cryptography; -using Microsoft.Extensions.Options; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using Musify.StreamingGateway.Authentication; @@ -25,12 +24,12 @@ public sealed class TicketValidatorTests : IDisposable File.Delete(publicKeyPath); } - private TicketValidator CreateValidator() => new(Options.Create(new StreamTicketValidationOptions + private TicketValidator CreateValidator() => new(new StreamTicketValidationConfiguration { PublicKeyPath = publicKeyPath, Audience = Audience, Issuer = Issuer - })); + }); private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience, DateTime? expires = null, DateTime? notBefore = null, long? maxBytes = null) diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs index caedf74..a4735f0 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs @@ -1,7 +1,6 @@ using System.Security.Cryptography; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using Musify.StreamingGateway.Authentication; @@ -17,12 +16,12 @@ public sealed class TicketValidationMiddlewareTests : IDisposable private readonly string publicKeyPath = Path.GetTempFileName(); private readonly RSA rsa = RSA.Create(2048); - private readonly StreamTicketValidationOptions options; + private readonly StreamTicketValidationConfiguration options; public TicketValidationMiddlewareTests() { File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); - options = new StreamTicketValidationOptions + options = new StreamTicketValidationConfiguration { PublicKeyPath = publicKeyPath, Audience = "media-gateway", @@ -57,8 +56,8 @@ public sealed class TicketValidationMiddlewareTests : IDisposable { 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); + var validator = new TicketValidator(options); + var middleware = new TicketValidationMiddleware(next, validator, options, NullLogger.Instance); return (middleware, () => nextCalled); } From 5a0f82b77bb21d09c7da6ebc3ce7f7f38ddb64a0 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 11:43:50 +0200 Subject: [PATCH 07/21] Clean up domain entities: shared picture value object, lifecycle and ownership interfaces, explicit tables --- .../Musify.Application/Albums/CreateAlbum.cs | 2 +- .../Musify.Application/Albums/UpdateAlbum.cs | 2 +- .../PlayLists/AddTrackToPlayList.cs | 2 +- .../PlayLists/CreatePlayList.cs | 8 +++---- .../PlayLists/DeletePlayList.cs | 2 +- .../PlayLists/GetPlayListById.cs | 4 ++-- .../PlayLists/GetPlayListCover.cs | 2 +- .../PlayLists/GetPlayListTracks.cs | 2 +- .../PlayLists/GetPlayListsByUserId.cs | 4 ++-- .../PlayLists/RemoveTrackFromPlayList.cs | 2 +- .../Responses/PlayListApplicationResponse.cs | 2 +- .../PlayLists/UpdatePlayList.cs | 6 +++--- .../Services/UploadIntentValidator.cs | 2 +- .../Abstractions/IHasLifeCycle.cs | 10 +++++++++ .../Abstractions/IOwnedEntity.cs | 8 +++++++ backend/Core/Musify.Domain/Entities/Album.cs | 14 ++++--------- .../Musify.Domain/Entities/AlbumHasTrack.cs | 10 ++------- .../Entities/ListeningHistory.cs | 8 ++----- backend/Core/Musify.Domain/Entities/Mix.cs | 10 ++------- .../Core/Musify.Domain/Entities/MixItem.cs | 10 +++------ .../Core/Musify.Domain/Entities/PlayList.cs | 21 +++++++------------ .../Entities/PlayListHasTrack.cs | 12 +++-------- backend/Core/Musify.Domain/Entities/Track.cs | 14 +++---------- .../Core/Musify.Domain/Entities/TrackLike.cs | 8 ++----- .../Core/Musify.Domain/Entities/TrackTag.cs | 6 ++---- .../Musify.Domain/Entities/UploadIntent.cs | 16 +++++--------- backend/Core/Musify.Domain/Entities/User.cs | 4 ---- .../Core/Musify.Domain/Entities/UserFollow.cs | 8 ++----- .../Musify.Domain/Entities/UserHasTrack.cs | 8 ++----- .../{AlbumPictures.cs => EntityPictures.cs} | 5 +++-- .../ValueObjects/PlayListPictures.cs | 17 --------------- .../ValueObjects/PlaylistVisibility.cs | 2 +- .../Pictures/UpdateAlbumPictureActivity.cs | 2 +- .../Pictures/UpdatePlayListPictureActivity.cs | 2 +- .../Persistence/Database.cs | 2 +- .../Migrations/DatabaseModelSnapshot.cs | 21 ++++++++++--------- .../PlayLists/CreatePlayListRequest.cs | 2 +- .../PlayLists/UpdatePlayListRequest.cs | 2 +- .../Albums/GetAlbumCoverQueryHandlerTests.cs | 2 +- .../CreatePlayListCommandHandlerTests.cs | 6 +++--- .../GetPlayListByIdQueryHandlerTests.cs | 10 ++++----- .../GetPlayListTracksQueryHandlerTests.cs | 4 ++-- .../GetPlayListsByUserIdQueryHandlerTests.cs | 8 +++---- .../UpdatePlayListCommandHandlerTests.cs | 8 +++---- .../TestSupport/TestEntities.cs | 12 +++++------ 45 files changed, 122 insertions(+), 190 deletions(-) create mode 100644 backend/Core/Musify.Domain/Abstractions/IHasLifeCycle.cs create mode 100644 backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs rename backend/Core/Musify.Domain/ValueObjects/{AlbumPictures.cs => EntityPictures.cs} (53%) delete mode 100644 backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index eac52f3..f2b9b9d 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -56,7 +56,7 @@ public class CreateAlbumCommandHandler( NormalizedTitle = title.ToUpperInvariant(), Description = request.Description, ReleaseYear = request.ReleaseYear, - Pictures = AlbumPictures.Pending(intent.ObjectName) + Pictures = EntityPictures.Pending(intent.ObjectName) }; await database.Albums.AddAsync(album, cancellationToken); diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index a6c9d7b..e874017 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -64,7 +64,7 @@ public class UpdateAlbumCommandHandler( return validation.Errors; var pictureIntent = validation.Value; - album.Pictures = AlbumPictures.Pending(pictureIntent.ObjectName); + album.Pictures = EntityPictures.Pending(pictureIntent.ObjectName); var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); var publishResult = await PublishUpdateAlbumPictureSourceEventAsync( diff --git a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs index 2c3f28f..3626d30 100644 --- a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs @@ -24,7 +24,7 @@ public class AddTrackToPlayListCommandHandler( if (playList == null) return AppErrors.NotFound("PlayList", request.PlayListId); - if (playList.UserId != request.UserId) + if (playList.OwnerUserId != request.UserId) return AppErrors.Forbidden("PlayList", request.PlayListId); var trackExists = await database.Tracks diff --git a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index d78024b..6b1d66b 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -17,7 +17,7 @@ public record CreatePlayListCommand( string Name, string? Description, Guid? PictureIntentId, - PlaylistVisibility Visibility = PlaylistVisibility.Private) + PlayListVisibility Visibility = PlayListVisibility.Private) : ICommand>; public class CreatePlayListCommandHandler( @@ -30,7 +30,7 @@ public class CreatePlayListCommandHandler( UploadIntentConfiguration uploadIntentConfiguration) : ICommandHandler> { - private sealed record ResolvedPlayListPictures(PlayListPictures? Pictures, UploadIntent? Intent); + private sealed record ResolvedPlayListPictures(EntityPictures? Pictures, UploadIntent? Intent); public async ValueTask> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) { @@ -51,7 +51,7 @@ public class CreatePlayListCommandHandler( var playList = new PlayList { - UserId = request.UserId, + OwnerUserId = request.UserId, Name = request.Name.Trim(), NormalizedName = request.Name.Trim().ToUpperInvariant(), Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), @@ -96,7 +96,7 @@ public class CreatePlayListCommandHandler( if (validation.IsError) return validation.Errors; - return new ResolvedPlayListPictures(PlayListPictures.Pending(validation.Value.ObjectName), validation.Value); + return new ResolvedPlayListPictures(EntityPictures.Pending(validation.Value.ObjectName), validation.Value); } private async Task> PublishCreatePlayListEventAsync( diff --git a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs index 710a5f4..e49b65c 100644 --- a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs @@ -27,7 +27,7 @@ public class DeletePlayListCommandHandler( return Error.NotFound(); } - if (playList.UserId != request.UserId) + if (playList.OwnerUserId != request.UserId) { logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId); return AppErrors.Forbidden("PlayList", request.PlayListId); diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs index 869f0e1..825861d 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs @@ -32,8 +32,8 @@ public class GetPlayListByIdQueryHandler(IDatabase database) if (entry == null) return Error.NotFound(); - if (entry.PlayList.Visibility == PlaylistVisibility.Private - && entry.PlayList.UserId != request.RequestingUserId) + if (entry.PlayList.Visibility == PlayListVisibility.Private + && entry.PlayList.OwnerUserId != request.RequestingUserId) { return Error.NotFound(); } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs index e456e7d..fab4d48 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs @@ -24,7 +24,7 @@ public class GetPlayListCoverQueryHandler( { var owned = await database.PlayLists.AsNoTracking() .Where(e => e.Id == request.PlayListId && e.LifeCycleStatus == LifeCycleStatus.Active) - .Select(e => new { OwnerUserId = e.UserId, e.Pictures }) + .Select(e => new { OwnerUserId = e.OwnerUserId, e.Pictures }) .SingleOrDefaultAsync(cancellationToken); if (owned?.Pictures == null) diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index f25cabc..d99ffee 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -27,7 +27,7 @@ public class GetPlayListTracksQueryHandler(IDatabase database) .AnyAsync( p => p.Id == request.PlayListId && p.LifeCycleStatus == LifeCycleStatus.Active - && (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId), + && (p.Visibility == PlayListVisibility.Public || p.OwnerUserId == request.ViewerId), cancellationToken); if (!playListExists) return Error.NotFound(); diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs index e292836..c9d2e05 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -25,9 +25,9 @@ public class GetPlayListsByUserIdQueryHandler(IDatabase database) { var playListsQuery = database.PlayLists .AsNoTracking() - .Where(p => p.UserId == request.UserId + .Where(p => p.OwnerUserId == request.UserId && p.LifeCycleStatus == LifeCycleStatus.Active - && (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId)); + && (p.Visibility == PlayListVisibility.Public || p.OwnerUserId == request.ViewerId)); if (!string.IsNullOrEmpty(request.Name)) { diff --git a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs index da372fe..6f4c9ca 100644 --- a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs @@ -23,7 +23,7 @@ public class RemoveTrackFromPlayListCommandHandler( if (playList == null) return AppErrors.NotFound("PlayList", request.PlayListId); - if (playList.UserId != request.UserId) + if (playList.OwnerUserId != request.UserId) return AppErrors.Forbidden("PlayList", request.PlayListId); var playListTracks = await database.PlayListHasTracks diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs index dedb055..527a324 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs @@ -10,7 +10,7 @@ public record PlayListApplicationResponse( string? SmallImageKeyName, string? MediumImageKeyName, string? LargeImageKeyName, - PlaylistVisibility Visibility, + PlayListVisibility Visibility, DateTime CreatedAt, DateTime UpdatedAt, IReadOnlyList CoverTrackIds) diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index e4c395a..6b329df 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -19,7 +19,7 @@ public record UpdatePlayListCommand( string? NewName, string? NewDescription, Guid? NewPictureIntentId, - PlaylistVisibility? NewVisibility = null) + PlayListVisibility? NewVisibility = null) : ICommand>; public class UpdatePlayListCommandHandler( @@ -41,7 +41,7 @@ public class UpdatePlayListCommandHandler( return Error.NotFound(); } - if (playListEntity.UserId != request.UserId) + if (playListEntity.OwnerUserId != request.UserId) { logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); return AppErrors.Forbidden("PlayList", request.PlayListId); @@ -72,7 +72,7 @@ public class UpdatePlayListCommandHandler( return validation.Errors; var pictureIntent = validation.Value; - playListEntity.Pictures = PlayListPictures.Pending(pictureIntent.ObjectName); + playListEntity.Pictures = EntityPictures.Pending(pictureIntent.ObjectName); var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); var publishResult = await PublishUpdatePlayListPictureSourceEventAsync( diff --git a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs index 954d769..a14bd68 100644 --- a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs @@ -52,7 +52,7 @@ public sealed class UploadIntentValidator( if (intent.IsConsumed) return Error.Conflict(description: "Upload intent has already been consumed."); - if (intent.IsExpired) + if (intent.IsExpiredAt(DateTime.UtcNow)) return Error.Validation(description: "Upload intent has expired."); var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken); diff --git a/backend/Core/Musify.Domain/Abstractions/IHasLifeCycle.cs b/backend/Core/Musify.Domain/Abstractions/IHasLifeCycle.cs new file mode 100644 index 0000000..6020728 --- /dev/null +++ b/backend/Core/Musify.Domain/Abstractions/IHasLifeCycle.cs @@ -0,0 +1,10 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Domain.Abstractions; + +public interface IHasLifeCycle +{ + public Guid Id { get; } + + public LifeCycleStatus LifeCycleStatus { get; set; } +} diff --git a/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs b/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs new file mode 100644 index 0000000..bb56244 --- /dev/null +++ b/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs @@ -0,0 +1,8 @@ +namespace Musify.Domain.Abstractions; + +public interface IOwnedEntity +{ + public Guid Id { get; } + + public long OwnerUserId { get; } +} diff --git a/backend/Core/Musify.Domain/Entities/Album.cs b/backend/Core/Musify.Domain/Entities/Album.cs index 31c5d46..0cfeca1 100644 --- a/backend/Core/Musify.Domain/Entities/Album.cs +++ b/backend/Core/Musify.Domain/Entities/Album.cs @@ -5,17 +5,15 @@ using Musify.Domain.ValueObjects; namespace Musify.Domain.Entities; -#pragma warning disable CS8618 -public class Album : IAuditable +[Table("Albums")] +public class Album : IAuditable, IHasLifeCycle, IOwnedEntity { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [Required] [MaxLength(200)] public required string Title { get; set; } - [Required] [MaxLength(200)] public required string NormalizedTitle { get; set; } @@ -24,21 +22,17 @@ public class Album : IAuditable public int? ReleaseYear { get; set; } - public AlbumPictures? Pictures { get; set; } + public EntityPictures? Pictures { get; set; } - [Required] public required long OwnerUserId { get; set; } [ForeignKey(nameof(OwnerUserId))] - public User Owner { get; set; } + public User Owner { get; set; } = null!; - [Required] public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; - [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [Required] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; 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 8ab9ad4..3648390 100644 --- a/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs +++ b/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs @@ -1,29 +1,23 @@ -using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace Musify.Domain.Entities; -#pragma warning disable CS8618 [Table("AlbumHasTrack")] public class AlbumHasTrack { public Guid Id { get; set; } = Guid.NewGuid(); - [Required] public required Guid AlbumId { get; set; } - [Required] public required Guid TrackId { get; set; } - [Required] public int TrackNumber { get; set; } = 1; - [Required] public DateTime AddedAt { get; set; } = DateTime.UtcNow; [ForeignKey(nameof(AlbumId))] - public Album Album { get; set; } + public Album Album { get; set; } = null!; [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } + public Track Track { get; set; } = null!; } diff --git a/backend/Core/Musify.Domain/Entities/ListeningHistory.cs b/backend/Core/Musify.Domain/Entities/ListeningHistory.cs index f1e0e5c..4a1afd1 100644 --- a/backend/Core/Musify.Domain/Entities/ListeningHistory.cs +++ b/backend/Core/Musify.Domain/Entities/ListeningHistory.cs @@ -3,20 +3,16 @@ using System.ComponentModel.DataAnnotations.Schema; namespace Musify.Domain.Entities; -#pragma warning disable CS8618 [Table("ListeningHistory")] public class ListeningHistory { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [Required] public required long UserId { get; set; } - [Required] public required Guid TrackId { get; set; } - [Required] public DateTime ListenedAt { get; set; } = DateTime.UtcNow; public double? PlayedSeconds { get; set; } @@ -26,8 +22,8 @@ public class ListeningHistory public bool IsCounted { get; set; } [ForeignKey(nameof(UserId))] - public User User { get; set; } + public User User { get; set; } = null!; [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } + public Track Track { get; set; } = null!; } diff --git a/backend/Core/Musify.Domain/Entities/Mix.cs b/backend/Core/Musify.Domain/Entities/Mix.cs index f2b44de..9487931 100644 --- a/backend/Core/Musify.Domain/Entities/Mix.cs +++ b/backend/Core/Musify.Domain/Entities/Mix.cs @@ -5,34 +5,28 @@ using Musify.Domain.Abstractions; namespace Musify.Domain.Entities; -#pragma warning disable CS8618 [Table("Mixes")] public class Mix : IAuditable { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [Required] public required long UserId { get; set; } - [Required] [MaxLength(100)] public required string Title { get; set; } [MaxLength(256)] public string? Subtitle { get; set; } - [Required] - public int Position { get; set; } = 0; + public int Position { get; set; } - [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [Required] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; [ForeignKey(nameof(UserId))] - public User User { get; set; } + public User User { get; set; } = null!; 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 35bac13..278e2b8 100644 --- a/backend/Core/Musify.Domain/Entities/MixItem.cs +++ b/backend/Core/Musify.Domain/Entities/MixItem.cs @@ -3,25 +3,21 @@ using System.ComponentModel.DataAnnotations.Schema; namespace Musify.Domain.Entities; -#pragma warning disable CS8618 [Table("MixItems")] public class MixItem { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [Required] public required Guid MixId { get; set; } - [Required] - public int Position { get; set; } = 0; + public int Position { get; set; } - [Required] public required Guid TrackId { get; set; } [ForeignKey(nameof(MixId))] - public Mix Mix { get; set; } + public Mix Mix { get; set; } = null!; [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } + public Track Track { get; set; } = null!; } diff --git a/backend/Core/Musify.Domain/Entities/PlayList.cs b/backend/Core/Musify.Domain/Entities/PlayList.cs index 0812771..7774958 100644 --- a/backend/Core/Musify.Domain/Entities/PlayList.cs +++ b/backend/Core/Musify.Domain/Entities/PlayList.cs @@ -6,43 +6,36 @@ using Musify.Domain.ValueObjects; namespace Musify.Domain.Entities; -#pragma warning disable CS8618 [Table("PlayLists")] -public class PlayList : IAuditable +public class PlayList : IAuditable, IHasLifeCycle, IOwnedEntity { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [Required] - public required long UserId { get; set; } + [Column("UserId")] + public required long OwnerUserId { get; set; } - [Required] [MaxLength(50)] public required string Name { get; set; } - [Required] [MaxLength(50)] public required string NormalizedName { get; set; } [MaxLength(256)] public string? Description { get; set; } - public PlayListPictures? Pictures { get; set; } + public EntityPictures? Pictures { get; set; } - [Required] public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; - [Required] - public PlaylistVisibility Visibility { get; set; } = PlaylistVisibility.Private; + public PlayListVisibility Visibility { get; set; } = PlayListVisibility.Private; - [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [Required] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(UserId))] - public User User { get; set; } + [ForeignKey(nameof(OwnerUserId))] + public User Owner { get; set; } = null!; 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 836fd6b..173bf5b 100644 --- a/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs +++ b/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs @@ -1,29 +1,23 @@ -using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace Musify.Domain.Entities; -#pragma warning disable CS8618 [Table("PlayListHasTrack")] public class PlayListHasTrack { public Guid Id { get; set; } = Guid.NewGuid(); - [Required] public required Guid PlayListId { get; set; } - [Required] public required Guid TrackId { get; set; } - [Required] - public int Position { get; set; } = 0; + public int Position { get; set; } - [Required] public DateTime AddedAt { get; set; } = DateTime.UtcNow; [ForeignKey(nameof(PlayListId))] - public PlayList PlayList { get; set; } + public PlayList PlayList { get; set; } = null!; [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } + public Track Track { get; set; } = null!; } diff --git a/backend/Core/Musify.Domain/Entities/Track.cs b/backend/Core/Musify.Domain/Entities/Track.cs index b1c352f..8474fed 100644 --- a/backend/Core/Musify.Domain/Entities/Track.cs +++ b/backend/Core/Musify.Domain/Entities/Track.cs @@ -5,39 +5,31 @@ using Musify.Domain.ValueObjects; namespace Musify.Domain.Entities; -#pragma warning disable CS8618 -public class Track : IAuditable +[Table("Tracks")] +public class Track : IAuditable, IHasLifeCycle, IOwnedEntity { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [Required] [MaxLength(200)] public required string Title { get; set; } - [Required] [MaxLength(200)] public required string NormalizedTitle { get; set; } - [Required] public double DurationSeconds { get; set; } - [Required] public required long OwnerUserId { get; set; } [ForeignKey(nameof(OwnerUserId))] - public required User Owner { get; set; } + public User Owner { get; set; } = null!; - [Required] public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; - [Required] public bool IsExplicit { get; set; } - [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [Required] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public TrackPictures Pictures { get; set; } = new(); diff --git a/backend/Core/Musify.Domain/Entities/TrackLike.cs b/backend/Core/Musify.Domain/Entities/TrackLike.cs index b605187..9cf41d3 100644 --- a/backend/Core/Musify.Domain/Entities/TrackLike.cs +++ b/backend/Core/Musify.Domain/Entities/TrackLike.cs @@ -3,25 +3,21 @@ using System.ComponentModel.DataAnnotations.Schema; namespace Musify.Domain.Entities; -#pragma warning disable CS8618 [Table("TrackLikes")] public class TrackLike { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [Required] public required long UserId { get; set; } - [Required] public required Guid TrackId { get; set; } - [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; [ForeignKey(nameof(UserId))] - public User User { get; set; } + public User User { get; set; } = null!; [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } + public Track Track { get; set; } = null!; } diff --git a/backend/Core/Musify.Domain/Entities/TrackTag.cs b/backend/Core/Musify.Domain/Entities/TrackTag.cs index 771c4cc..0f599a4 100644 --- a/backend/Core/Musify.Domain/Entities/TrackTag.cs +++ b/backend/Core/Musify.Domain/Entities/TrackTag.cs @@ -4,18 +4,16 @@ using Musify.Domain.ValueObjects; namespace Musify.Domain.Entities; -#pragma warning disable CS8618 +[Table("TrackTags")] public class TrackTag { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [Required] public required Guid TrackId { get; set; } - [Required] public required Genre Tag { get; set; } [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } + public Track Track { get; set; } = null!; } diff --git a/backend/Core/Musify.Domain/Entities/UploadIntent.cs b/backend/Core/Musify.Domain/Entities/UploadIntent.cs index 0b48567..f3fc802 100644 --- a/backend/Core/Musify.Domain/Entities/UploadIntent.cs +++ b/backend/Core/Musify.Domain/Entities/UploadIntent.cs @@ -11,40 +11,34 @@ public class UploadIntent [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [Required] public required long UserId { get; set; } - [Required, MaxLength(128)] + [MaxLength(128)] public required string Bucket { get; set; } - [Required, MaxLength(512)] + [MaxLength(512)] public required string Key { get; set; } - [Required, MaxLength(128)] + [MaxLength(128)] public required string ObjectName { get; set; } - [Required, MaxLength(128)] + [MaxLength(128)] public required string ContentType { get; set; } public long? ExpectedSizeBytes { get; set; } - [Required] public UploadIntentPurpose Purpose { get; set; } - [Required] public UploadIntentStatus Status { get; set; } = UploadIntentStatus.Issued; - [Required] public DateTime ExpiresAt { get; set; } - [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; [NotMapped] public bool IsConsumed => Status == UploadIntentStatus.Consumed; - [NotMapped] - public bool IsExpired => Status == UploadIntentStatus.Expired || ExpiresAt < DateTime.UtcNow; + public bool IsExpiredAt(DateTime now) => Status == UploadIntentStatus.Expired || ExpiresAt < now; [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 2a8c5f6..e12912c 100644 --- a/backend/Core/Musify.Domain/Entities/User.cs +++ b/backend/Core/Musify.Domain/Entities/User.cs @@ -11,11 +11,9 @@ public class User : IAuditable [DatabaseGenerated(DatabaseGeneratedOption.None)] public required long Id { get; set; } - [Required] [MaxLength(48)] public required string Name { get; set; } - [Required] [MaxLength(48)] public required string NormalizedName { get; set; } @@ -28,10 +26,8 @@ public class User : IAuditable [MaxLength(2048)] public string? ProfilePictureUrl { get; set; } - [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [Required] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public ICollection UserTracks { get; set; } = new List(); diff --git a/backend/Core/Musify.Domain/Entities/UserFollow.cs b/backend/Core/Musify.Domain/Entities/UserFollow.cs index 202c6a0..93b324c 100644 --- a/backend/Core/Musify.Domain/Entities/UserFollow.cs +++ b/backend/Core/Musify.Domain/Entities/UserFollow.cs @@ -3,25 +3,21 @@ using System.ComponentModel.DataAnnotations.Schema; namespace Musify.Domain.Entities; -#pragma warning disable CS8618 [Table("UserFollows")] public class UserFollow { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [Required] public required long FollowerId { get; set; } - [Required] public required long FollowedId { get; set; } - [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; [ForeignKey(nameof(FollowerId))] - public User Follower { get; set; } + public User Follower { get; set; } = null!; [ForeignKey(nameof(FollowedId))] - public User Followed { get; set; } + public User Followed { get; set; } = null!; } diff --git a/backend/Core/Musify.Domain/Entities/UserHasTrack.cs b/backend/Core/Musify.Domain/Entities/UserHasTrack.cs index a1ad0be..d2998b9 100644 --- a/backend/Core/Musify.Domain/Entities/UserHasTrack.cs +++ b/backend/Core/Musify.Domain/Entities/UserHasTrack.cs @@ -1,23 +1,19 @@ -using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace Musify.Domain.Entities; -#pragma warning disable CS8618 [Table("UserHasTrack")] public class UserHasTrack { public Guid Id { get; set; } = Guid.NewGuid(); - [Required] public required long UserId { get; set; } - [Required] public required Guid TrackId { get; set; } [ForeignKey(nameof(UserId))] - public User User { get; set; } + public User User { get; set; } = null!; [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } + public Track Track { get; set; } = null!; } diff --git a/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs b/backend/Core/Musify.Domain/ValueObjects/EntityPictures.cs similarity index 53% rename from backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs rename to backend/Core/Musify.Domain/ValueObjects/EntityPictures.cs index c505f0a..9cc8db5 100644 --- a/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs +++ b/backend/Core/Musify.Domain/ValueObjects/EntityPictures.cs @@ -2,7 +2,8 @@ using System.ComponentModel.DataAnnotations; namespace Musify.Domain.ValueObjects; -public class AlbumPictures +/// The picture file names of an album or playlist. Resized names stay null until processing finishes. +public class EntityPictures { [Required] public required string OriginalName { get; set; } @@ -13,5 +14,5 @@ public class AlbumPictures public string? LargeName { get; set; } - public static AlbumPictures Pending(string originalName) => new() { OriginalName = originalName }; + public static EntityPictures Pending(string objectName) => new() { OriginalName = objectName }; } diff --git a/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs b/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs deleted file mode 100644 index 3617896..0000000 --- a/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Musify.Domain.ValueObjects; - -public class PlayListPictures -{ - [Required] - public required string OriginalName { get; set; } - - public string? SmallName { get; set; } - - public string? MediumName { get; set; } - - public string? LargeName { get; set; } - - public static PlayListPictures Pending(string originalName) => new() { OriginalName = originalName }; -} diff --git a/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs b/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs index e2769f0..b6574d8 100644 --- a/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs +++ b/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs @@ -1,6 +1,6 @@ namespace Musify.Domain.ValueObjects; -public enum PlaylistVisibility +public enum PlayListVisibility { Private = 0, Public = 1 diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs index 6f9b0a2..9b5d1c1 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs @@ -89,7 +89,7 @@ internal class UpdateAlbumPictureActivity( return compensateContext.Compensated(); } - album.Pictures = new AlbumPictures + album.Pictures = new EntityPictures { OriginalName = compensateContext.Log.PreviousOriginalPictureKey, SmallName = compensateContext.Log.PreviousSmallPictureKey, diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs index f5ab673..d62995b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs @@ -89,7 +89,7 @@ internal class UpdatePlayListPictureActivity( return compensateContext.Compensated(); } - playList.Pictures = new PlayListPictures + playList.Pictures = new EntityPictures { OriginalName = compensateContext.Log.PreviousOriginalPictureKey, SmallName = compensateContext.Log.PreviousSmallPictureKey, diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index e2b04b2..70e5e9f 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -64,7 +64,7 @@ public class Database(DatabaseConfiguration configuration) modelBuilder.Entity() .Property(playList => playList.Visibility) - .HasDefaultValue(PlaylistVisibility.Private); + .HasDefaultValue(PlayListVisibility.Private); modelBuilder.Entity() .OwnsOne(playList => playList.Pictures, pictures => diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index f543d34..1ad303c 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -379,12 +379,13 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasMaxLength(50) .HasColumnType("character varying(50)"); + b.Property("OwnerUserId") + .HasColumnType("bigint") + .HasColumnName("UserId"); + b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); - b.Property("UserId") - .HasColumnType("bigint"); - b.Property("Visibility") .ValueGeneratedOnAdd() .HasColumnType("integer") @@ -392,7 +393,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("Id"); - b.HasIndex("UserId"); + b.HasIndex("OwnerUserId"); b.ToTable("PlayLists"); }); @@ -756,7 +757,7 @@ namespace Musify.Infrastructure.Persistence.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); - b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => { b1.Property("AlbumId") .HasColumnType("uuid"); @@ -865,13 +866,13 @@ namespace Musify.Infrastructure.Persistence.Migrations modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => { - b.HasOne("Musify.Domain.Entities.User", "User") + b.HasOne("Musify.Domain.Entities.User", "Owner") .WithMany("PlayLists") - .HasForeignKey("UserId") + .HasForeignKey("OwnerUserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => { b1.Property("PlayListId") .HasColumnType("uuid"); @@ -904,9 +905,9 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasForeignKey("PlayListId"); }); - b.Navigation("Pictures"); + b.Navigation("Owner"); - b.Navigation("User"); + b.Navigation("Pictures"); }); modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs index bc5910e..5da333d 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs @@ -6,4 +6,4 @@ public record CreatePlayListRequest( string Name, string? Description, Guid? PictureIntentId, - PlaylistVisibility Visibility = PlaylistVisibility.Private); + PlayListVisibility Visibility = PlayListVisibility.Private); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs index 0ccf8fc..ddf8b87 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs @@ -6,4 +6,4 @@ public record UpdatePlayListRequest( string? NewName, string? NewDescription, Guid? NewPictureIntentId, - PlaylistVisibility? NewVisibility = null); + PlayListVisibility? NewVisibility = null); diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs index 4b84e3d..00c82be 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs @@ -39,7 +39,7 @@ public sealed class GetAlbumCoverQueryHandlerTests : HandlerTestBase public async Task Handle_PicturesStillProcessing_FallsBackToTheOriginal() { var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id, pictures: Musify.Domain.ValueObjects.AlbumPictures.Pending("original.png")); + var album = TestEntities.Album(owner.Id, pictures: Musify.Domain.ValueObjects.EntityPictures.Pending("original.png")); await SeedAsync(owner, album); var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, "small"), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs index 4a9ea17..a724a5a 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs @@ -71,7 +71,7 @@ public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Private, result.Value.Visibility); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Private, result.Value.Visibility); } [Fact] @@ -80,12 +80,12 @@ public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase var user = TestEntities.User(); await SeedAsync(user); - var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null, Visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null, Visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Public, result.Value.Visibility); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs index e393c0d..1d7a5b7 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs @@ -14,7 +14,7 @@ public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds() { var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); + 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 }); @@ -37,7 +37,7 @@ public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase public async Task Handle_PrivatePlayList_RequestedByOwner_ReturnsIt() { var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private); + 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); @@ -50,7 +50,7 @@ public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private); + 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); @@ -62,7 +62,7 @@ public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase public async Task Handle_PrivatePlayList_RequestedAnonymously_ReturnsNotFound() { var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private); + 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); @@ -74,7 +74,7 @@ public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase public async Task Handle_PublicPlayList_RequestedAnonymously_ReturnsIt() { var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); + 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); diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs index 013402f..f97d132 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs @@ -41,7 +41,7 @@ public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase public async Task Handle_PrivatePlayListOfAnotherUser_ReturnsNotFound() { var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: PlaylistVisibility.Private); + var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private); await SeedAsync(owner, playList); var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); @@ -53,7 +53,7 @@ public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase public async Task Handle_PrivatePlayListOfViewer_ReturnsTracks() { var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: PlaylistVisibility.Private); + var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private); await SeedAsync(owner, playList); var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs index 51eba29..5ffe334 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs @@ -33,8 +33,8 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase 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)); + TestEntities.PlayList(owner.Id, "Public one", visibility: PlayListVisibility.Public), + TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private)); var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); @@ -49,8 +49,8 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase 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)); + TestEntities.PlayList(owner.Id, "Public one", visibility: PlayListVisibility.Public), + TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private)); var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs index fc3c8e7..848f58c 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs @@ -69,19 +69,19 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase var playList = TestEntities.PlayList(owner.Id); await SeedAsync(owner, playList); - var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlaylistVisibility.Public); + var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlayListVisibility.Public); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Public, result.Value.Visibility); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); } [Fact] public async Task Handle_NoVisibilitySpecified_LeavesItUnchanged() { var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); await SeedAsync(owner, playList); var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); @@ -89,7 +89,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Public, result.Value.Visibility); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs index bf5b8f8..b058d4d 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -79,7 +79,7 @@ public static class TestEntities return track; } - public static PlayListPictures PlayListPictures() => new() + public static EntityPictures PlayListPictures() => new() { OriginalName = "original.webp", SmallName = "small.webp", @@ -91,12 +91,12 @@ public static class TestEntities long userId, string name = "Test Playlist", string? description = null, - PlayListPictures? pictures = null, + EntityPictures? pictures = null, LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, - PlaylistVisibility visibility = PlaylistVisibility.Private) => + PlayListVisibility visibility = PlayListVisibility.Private) => new() { - UserId = userId, + OwnerUserId = userId, Name = name, NormalizedName = name.ToUpperInvariant(), Description = description, @@ -105,7 +105,7 @@ public static class TestEntities Visibility = visibility }; - public static AlbumPictures AlbumPictures() => new() + public static EntityPictures AlbumPictures() => new() { OriginalName = "original.webp", SmallName = "small.webp", @@ -118,7 +118,7 @@ public static class TestEntities string title = "Test Album", string? description = null, int? releaseYear = 2024, - AlbumPictures? pictures = null) => + EntityPictures? pictures = null) => new() { Title = title, From 4b147d9ee33ecfc676eb776be0a25486cabff8f1 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 11:59:34 +0200 Subject: [PATCH 08/21] Add shared pagination, projections and ownership helpers; simplify query and command handlers --- .../Albums/AddTrackToAlbum.cs | 22 +++------ .../Musify.Application/Albums/CreateAlbum.cs | 5 +- .../Musify.Application/Albums/DeleteAlbum.cs | 14 ++---- .../Musify.Application/Albums/GetAlbumById.cs | 23 +++------ .../Albums/GetAlbumTracks.cs | 31 ++++-------- .../Musify.Application/Albums/GetAlbums.cs | 48 +++++------------- .../Albums/GetAlbumsByUserId.cs | 34 +++---------- .../Albums/GetRecentlyListenedAlbums.cs | 38 ++++++-------- .../Albums/RemoveTrackFromAlbum.cs | 18 ++----- .../Responses/AlbumApplicationResponse.cs | 2 +- .../Albums/Responses/AlbumProjections.cs | 31 ++++++++++++ .../Responses/AlbumSearchItemResponse.cs | 6 --- .../Albums/Responses/AlbumsSearchResponse.cs | 11 ----- .../Musify.Application/Albums/UpdateAlbum.cs | 4 +- .../Musify.Application/Application.csproj | 2 - .../Genres/GetAvailableGenres.cs | 3 +- .../Musify.Application/Genres/GetGenres.cs | 3 +- .../Responses/AvailableGenreResponse.cs | 5 ++ .../Genres/Responses/GenreResponse.cs | 5 ++ .../Likes/GetLikedTracks.cs | 31 ++++-------- .../Likes/ToggleTrackLike.cs | 8 ++- .../Mixes/GenerateMixesForUser.cs | 16 ++---- .../Musify.Application/Mixes/GetMixById.cs | 3 +- .../Mixes/GetMixesByUserId.cs | 8 +-- .../PlayLists/AddTrackToPlayList.cs | 14 ++---- .../PlayLists/CreatePlayList.cs | 5 +- .../PlayLists/DeletePlayList.cs | 44 ++++------------- .../PlayLists/GetPlayListById.cs | 31 ++++-------- .../PlayLists/GetPlayListTracks.cs | 41 ++++++---------- .../PlayLists/GetPlayListsByUserId.cs | 48 ++++++------------ .../PlayLists/RemoveTrackFromPlayList.cs | 11 ++--- .../Responses/PlayListApplicationResponse.cs | 19 +++++-- .../Responses/PlayListProjections.cs | 37 ++++++++++++++ .../PlayLists/UpdatePlayList.cs | 4 +- .../Services/UploadIntentValidator.cs | 9 ++-- .../Shared/OwnershipExtensions.cs | 28 +++++++++++ .../Shared/PaginatedResponse.cs | 25 ---------- .../Musify.Application/Shared/Pagination.cs | 39 +++++++++++++++ .../Shared/TextNormalizer.cs | 6 +++ .../Musify.Application/Tracks/CreateTrack.cs | 5 +- .../Musify.Application/Tracks/DeleteTrack.cs | 49 ++++--------------- .../Tracks/GetListeningHistory.cs | 20 ++++---- .../Musify.Application/Tracks/GetTrackById.cs | 19 ++++--- .../Tracks/GetTrackCover.cs | 15 +++--- .../Tracks/GetTrackStream.cs | 8 ++- .../Musify.Application/Tracks/GetTracks.cs | 48 ++++++------------ .../Tracks/GetTracksByUserId.cs | 33 +++++-------- .../Tracks/RecordListeningProgress.cs | 3 +- .../Tracks/Responses/TrackProjections.cs | 24 +++++++++ .../Responses/TrackSearchItemResponse.cs | 6 --- .../Tracks/Responses/TracksSearchResponse.cs | 11 ----- .../Musify.Application/Users/FollowUser.cs | 9 ++-- .../Users/GetListeningStats.cs | 3 +- .../Musify.Application/Users/GetUserById.cs | 8 +-- .../Users/GetUserFollowers.cs | 19 +++---- .../Users/GetUserFollowing.cs | 17 +++---- .../Users/GetUserProfile.cs | 18 ++----- .../Core/Musify.Application/Users/GetUsers.cs | 37 +++++++------- .../Users/Responses/ListeningStatsResponse.cs | 3 ++ .../Users/Responses/UserProfileResponse.cs | 16 ++++++ .../Core/Musify.Application/Users/SyncUser.cs | 9 ++-- .../Musify.Application/Users/UnfollowUser.cs | 4 +- .../Abstractions/IOwnedEntity.cs | 2 - backend/Directory.Packages.props | 2 - .../Musify.Api/Endpoints/AlbumEndpoints.cs | 6 +-- .../Musify.Api/Endpoints/GenreEndpoints.cs | 1 + .../Musify.Api/Endpoints/LikeEndpoints.cs | 2 +- .../Musify.Api/Endpoints/MixEndpoints.cs | 2 +- .../Musify.Api/Endpoints/PlayListEndpoints.cs | 2 +- .../Musify.Api/Endpoints/TrackEndpoints.cs | 4 +- .../Musify.Api/Endpoints/UserEndpoints.cs | 2 +- .../Endpoints/GenreEndpointsTests.cs | 13 ++--- .../Albums/GetAlbumsQueryHandlerTests.cs | 18 +++---- ...RecentlyListenedAlbumsQueryHandlerTests.cs | 14 ++---- .../GetAvailableGenresQueryHandlerTests.cs | 1 + .../Likes/GetLikedTracksQueryHandlerTests.cs | 12 ++--- .../GetMixesByUserIdQueryHandlerTests.cs | 8 +-- .../GetPlayListsByUserIdQueryHandlerTests.cs | 16 ++---- .../Tracks/GetTracksQueryHandlerTests.cs | 18 +++---- .../Users/GetUsersQueryHandlerTests.cs | 12 ++--- web-player/src/lib/api/schema.d.ts | 49 +++++-------------- .../src/routes/(app)/explore/+page.server.ts | 2 +- .../src/routes/(app)/explore/+page.svelte | 10 ++-- web-player/src/routes/+page.server.ts | 2 +- 84 files changed, 547 insertions(+), 767 deletions(-) create mode 100644 backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs delete mode 100644 backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs delete mode 100644 backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs create mode 100644 backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs create mode 100644 backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs create mode 100644 backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs create mode 100644 backend/Core/Musify.Application/Shared/OwnershipExtensions.cs delete mode 100644 backend/Core/Musify.Application/Shared/PaginatedResponse.cs create mode 100644 backend/Core/Musify.Application/Shared/TextNormalizer.cs create mode 100644 backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs delete mode 100644 backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs delete mode 100644 backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs create mode 100644 backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs create mode 100644 backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs diff --git a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs index 633dbb3..ee2902e 100644 --- a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs +++ b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs @@ -18,23 +18,13 @@ public class AddTrackToAlbumCommandHandler( { public async ValueTask> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken) { - var album = await database.Albums - .AsNoTracking() - .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); - if (album == null) - return AppErrors.NotFound("Album", request.AlbumId); + var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (album.IsError) + return album.Errors; - if (album.OwnerUserId != request.UserId) - return AppErrors.Forbidden("Album", request.AlbumId); - - var track = await database.Tracks - .AsNoTracking() - .SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); - if (track == null) - return AppErrors.NotFound("Track", request.TrackId); - - if (track.OwnerUserId != request.UserId) - return AppErrors.Forbidden("Track", request.TrackId); + var track = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken); + if (track.IsError) + return track.Errors; var alreadyAdded = await database.AlbumHasTracks .AsNoTracking() diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index f2b9b9d..5c37244 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Musify.Application.Albums.Responses; using Musify.Application.Configuration; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Application.Events; using Musify.Application.Services; using Musify.Domain.Entities; @@ -53,7 +54,7 @@ public class CreateAlbumCommandHandler( { OwnerUserId = request.UserId, Title = title, - NormalizedTitle = title.ToUpperInvariant(), + NormalizedTitle = TextNormalizer.Normalize(title), Description = request.Description, ReleaseYear = request.ReleaseYear, Pictures = EntityPictures.Pending(intent.ObjectName) @@ -101,7 +102,7 @@ public class CreateAlbumCommandHandler( sizes.Medium, sizes.Large), cancellationToken); - return new Success(); + return Result.Success; } catch (Exception exception) { diff --git a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs index 27787e9..bb4a96e 100644 --- a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs +++ b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs @@ -1,6 +1,5 @@ using ErrorOr; using Mediator; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Events; @@ -20,20 +19,17 @@ public class DeleteAlbumCommandHandler( { public async ValueTask> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) { - var album = await database.Albums - .SingleOrDefaultAsync(a => a.Id == request.AlbumId && a.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); - if (album == null) - return AppErrors.NotFound("Album", request.AlbumId); - - if (album.OwnerUserId != request.UserId) - return AppErrors.Forbidden("Album", request.AlbumId); + var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + var album = found.Value; album.LifeCycleStatus = LifeCycleStatus.Removing; await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken); await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Marked album {AlbumId} of user {UserId} as removing", request.AlbumId, request.UserId); + logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId); return Result.Success; } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumById.cs b/backend/Core/Musify.Application/Albums/GetAlbumById.cs index 8a72541..20856b6 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumById.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumById.cs @@ -3,7 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; +using Musify.Application.Shared; namespace Musify.Application.Albums; @@ -17,22 +17,13 @@ public class GetAlbumByIdQueryHandler(IDatabase database) { var album = await database.Albums .AsNoTracking() - .Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active) - .Select(a => new - { - Album = a, - TrackCount = a.AlbumTracks.Count, - CoverTrackIds = a.AlbumTracks - .OrderBy(albumTrack => albumTrack.TrackNumber) - .Take(AlbumApplicationResponse.CoverTrackCount) - .Select(albumTrack => albumTrack.TrackId) - .ToList() - }) + .Active() + .Where(a => a.Id == request.Id) + .SelectResponse() .SingleOrDefaultAsync(cancellationToken); - if (album == null) - return Error.NotFound(); - - return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds); + return album == null + ? AppErrors.NotFound("Album", request.Id) + : album; } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs index 9138cc7..ba9955a 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs @@ -1,12 +1,10 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using X.PagedList; -using X.PagedList.EF; -using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -23,27 +21,18 @@ public class GetAlbumTracksQueryHandler(IDatabase database) { var albumExists = await database.Albums .AsNoTracking() - .AnyAsync(album => album.Id == request.AlbumId && album.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); + .Active() + .AnyAsync(album => album.Id == request.AlbumId, cancellationToken); if (!albumExists) - return Error.NotFound(); + return AppErrors.NotFound("Album", request.AlbumId); - var tracksQuery = database.AlbumHasTracks + return await database.AlbumHasTracks .AsNoTracking() - .Include(albumTrack => albumTrack.Track.Owner) - .Include(albumTrack => albumTrack.Track.Tags) - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active); - - var totalCount = await tracksQuery.CountAsync(cancellationToken); - - var pagedEntities = await tracksQuery + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) .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); - - return PaginatedResponse.FromPagedList(pagedTracks); + .ThenBy(albumTrack => albumTrack.Id) + .Select(albumTrack => albumTrack.Track) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbums.cs b/backend/Core/Musify.Application/Albums/GetAlbums.cs index 7bec513..0c7143e 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbums.cs @@ -1,59 +1,33 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; -using X.PagedList.EF; -using Musify.Domain.ValueObjects; +using Musify.Application.Shared; namespace Musify.Application.Albums; public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) - : IQuery>; + : IQuery>; public class GetAlbumsQueryHandler(IDatabase database) - : IQueryHandler> + : IQueryHandler> { - public async ValueTask> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) { var albumsQuery = database.Albums .AsNoTracking() - .Where(album => album.LifeCycleStatus == LifeCycleStatus.Active); + .Active(); - if (!string.IsNullOrEmpty(request.Title)) + if (!string.IsNullOrWhiteSpace(request.Title)) { - var normalizedTitle = request.Title.Trim().ToUpperInvariant(); + var normalizedTitle = TextNormalizer.Normalize(request.Title); albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle)); } - var totalCount = await albumsQuery.CountAsync(cancellationToken); - - var pagedEntities = await albumsQuery + return 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); + .ThenBy(album => album.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs index f9f18ac..dfe03d2 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs @@ -4,9 +4,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using Musify.Application.Shared; -using X.PagedList; -using X.PagedList.EF; -using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -22,32 +19,15 @@ public class GetAlbumsByUserIdQueryHandler(IDatabase database) .AsNoTracking() .AnyAsync(user => user.Id == request.UserId, cancellationToken); if (!userExists) - return Error.NotFound(description: $"User {request.UserId} not found"); + return AppErrors.NotFound("User", request.UserId); - var albumsQuery = database.Albums + return await database.Albums .AsNoTracking() - .Where(album => album.OwnerUserId == request.UserId && album.LifeCycleStatus == LifeCycleStatus.Active); - - var totalCount = await albumsQuery.CountAsync(cancellationToken); - - var pagedEntities = await albumsQuery + .Active() + .Where(album => album.OwnerUserId == request.UserId) .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); - - return PaginatedResponse.FromPagedList(pagedAlbums); + .ThenBy(album => album.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs index bcee2e1..c038271 100644 --- a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs @@ -1,61 +1,51 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit) - : IQuery>>; + : IQuery>; public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler> { - public async ValueTask>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) { - var listenedTrackIds = database.ListeningHistories + var listens = database.ListeningHistories .AsNoTracking() .Where(history => history.UserId == request.UserId && history.IsCounted); var ranked = await database.AlbumHasTracks .AsNoTracking() + .Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) .Join( - listenedTrackIds, + listens, albumTrack => albumTrack.TrackId, history => history.TrackId, (albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt }) .GroupBy(entry => entry.AlbumId) .Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) }) .OrderByDescending(entry => entry.LastListenedAt) + .ThenBy(entry => entry.AlbumId) .Take(request.Limit) .ToListAsync(cancellationToken); if (ranked.Count == 0) - return new List(); + return []; var albumIds = ranked.Select(entry => entry.AlbumId).ToList(); - var albums = await database.Albums + var albumsById = await database.Albums .AsNoTracking() - .Where(album => albumIds.Contains(album.Id) && album.LifeCycleStatus == LifeCycleStatus.Active) - .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); + .Where(album => albumIds.Contains(album.Id)) + .SelectResponse() + .ToDictionaryAsync(album => 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)) + .Where(entry => albumsById.ContainsKey(entry.AlbumId)) + .Select(entry => albumsById[entry.AlbumId]) .ToList(); } } diff --git a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs index b84c21c..2edb9c9 100644 --- a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs +++ b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs @@ -17,14 +17,9 @@ public class RemoveTrackFromAlbumCommandHandler( { public async ValueTask> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) { - var album = await database.Albums - .AsNoTracking() - .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); - if (album == null) - return AppErrors.NotFound("Album", request.AlbumId); - - if (album.OwnerUserId != request.UserId) - return AppErrors.Forbidden("Album", request.AlbumId); + var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (album.IsError) + return album.Errors; var albumTracks = await database.AlbumHasTracks .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) @@ -33,10 +28,7 @@ public class RemoveTrackFromAlbumCommandHandler( 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."); - } + return Error.NotFound("Album.TrackNotFound", "Track is not in the album."); database.AlbumHasTracks.Remove(link); @@ -48,6 +40,6 @@ public class RemoveTrackFromAlbumCommandHandler( logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId); - return new Success(); + return Result.Success; } } diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs index cf5731b..eae6f11 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs @@ -18,7 +18,7 @@ public record AlbumApplicationResponse( DateTime UpdatedAt, IReadOnlyList CoverTrackIds) { - public const int CoverTrackCount = 4; + public const int CoverTrackCount = AlbumProjections.CoverTrackCount; public static AlbumApplicationResponse FromEntity( Album album, diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs new file mode 100644 index 0000000..196d1d2 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs @@ -0,0 +1,31 @@ +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Albums.Responses; + +public static class AlbumProjections +{ + public const int CoverTrackCount = 4; + + public static IQueryable Active(this IQueryable query) => + query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active); + + public static IQueryable SelectResponse(this IQueryable query) => + query.Select(album => new AlbumApplicationResponse( + album.Id, + album.Title, + album.Description, + album.ReleaseYear, + album.OwnerUserId, + album.AlbumTracks.Count, + album.Pictures!.SmallName, + album.Pictures!.MediumName, + album.Pictures!.LargeName, + album.CreatedAt, + album.UpdatedAt, + album.AlbumTracks + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Take(CoverTrackCount) + .Select(albumTrack => albumTrack.TrackId) + .ToList())); +} diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs deleted file mode 100644 index 600494b..0000000 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Musify.Application.Albums.Responses; - -public record AlbumSearchItemResponse(AlbumApplicationResponse 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 deleted file mode 100644 index aa88d5b..0000000 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Musify.Application.Albums.Responses; - - -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 e874017..4d58142 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -51,7 +51,7 @@ public class UpdateAlbumCommandHandler( var title = request.Title.Trim(); album.Title = title; - album.NormalizedTitle = title.ToUpperInvariant(); + album.NormalizedTitle = TextNormalizer.Normalize(title); album.Description = request.Description; album.ReleaseYear = request.ReleaseYear; @@ -118,7 +118,7 @@ public class UpdateAlbumCommandHandler( sizes.Medium, sizes.Large), cancellationToken); - return new Success(); + return Result.Success; } catch (Exception exception) { diff --git a/backend/Core/Musify.Application/Application.csproj b/backend/Core/Musify.Application/Application.csproj index a424aa4..c1b27d7 100644 --- a/backend/Core/Musify.Application/Application.csproj +++ b/backend/Core/Musify.Application/Application.csproj @@ -19,8 +19,6 @@ - - diff --git a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs index 733ba22..cffdd5f 100644 --- a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs @@ -1,12 +1,11 @@ using Mediator; using Musify.Domain.ValueObjects; +using Musify.Application.Genres.Responses; namespace Musify.Application.Genres; public record GetAvailableGenresQuery : IQuery>; -public record AvailableGenreResponse(Genre Genre, IReadOnlyList IncompatibleWith); - public class GetAvailableGenresQueryHandler : IQueryHandler> { diff --git a/backend/Core/Musify.Application/Genres/GetGenres.cs b/backend/Core/Musify.Application/Genres/GetGenres.cs index f85cbba..4d54270 100644 --- a/backend/Core/Musify.Application/Genres/GetGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetGenres.cs @@ -2,13 +2,12 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Domain.ValueObjects; +using Musify.Application.Genres.Responses; namespace Musify.Application.Genres; public record GetGenresQuery : IQuery>; -public record GenreResponse(Genre Genre, int TrackCount); - public class GetGenresQueryHandler(IDatabase database) : IQueryHandler> { diff --git a/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs b/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs new file mode 100644 index 0000000..2ff905e --- /dev/null +++ b/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs @@ -0,0 +1,5 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Genres.Responses; + +public record AvailableGenreResponse(Genre Genre, IReadOnlyList IncompatibleWith); diff --git a/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs b/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs new file mode 100644 index 0000000..60ae876 --- /dev/null +++ b/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs @@ -0,0 +1,5 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Genres.Responses; + +public record GenreResponse(Genre Genre, int TrackCount); diff --git a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs index d0468ad..3087e61 100644 --- a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs +++ b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs @@ -1,11 +1,8 @@ -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.Domain.ValueObjects; namespace Musify.Application.Likes; @@ -14,30 +11,20 @@ public record GetLikedTracksQuery( long UserId, int PageNumber, int PageSize) - : IQuery>>; + : IQuery>; public class GetLikedTracksQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler> { - public async ValueTask>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) { - var likesQuery = database.TrackLikes + return await database.TrackLikes .AsNoTracking() - .Include(like => like.Track.Owner) - .Include(like => like.Track.Tags) - .Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active); - - var totalCount = await likesQuery.CountAsync(cancellationToken); - - var pagedEntities = await likesQuery + .Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active) .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); - - return PaginatedResponse.FromPagedList(pagedTracks); + .ThenBy(like => like.Id) + .Select(like => like.Track) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs index 468a655..352eb0a 100644 --- a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs +++ b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; namespace Musify.Application.Likes; @@ -20,12 +21,9 @@ public class ToggleTrackLikeCommandHandler( { var trackExists = await database.Tracks .AsNoTracking() - .AnyAsync(t => t.Id == request.TrackId, cancellationToken); + .AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); if (!trackExists) - { - logger.LogInformation("Track {TrackId} not found", request.TrackId); - return Error.NotFound(description: $"Track {request.TrackId} not found"); - } + return AppErrors.NotFound("Track", request.TrackId); var existingLike = await database.TrackLikes .SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken); diff --git a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs index bc45f5e..587aa3a 100644 --- a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs +++ b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -33,7 +33,7 @@ public class GenerateMixesForUserCommandHandler( if (libraryTrackIds.Count == 0) { logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId); - return new Success(); + return Result.Success; } var historyTrackIds = await database.ListeningHistories @@ -69,23 +69,15 @@ public class GenerateMixesForUserCommandHandler( if (drafts.Count == 0) { logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId); - return new Success(); + return Result.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}"); - } + await database.SaveChangesAsync(cancellationToken); logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId); - return new Success(); + return Result.Success; } private List Take(IEnumerable trackIds) => diff --git a/backend/Core/Musify.Application/Mixes/GetMixById.cs b/backend/Core/Musify.Application/Mixes/GetMixById.cs index 75752ac..a831c12 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixById.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixById.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Mixes.Responses; +using Musify.Application.Shared; namespace Musify.Application.Mixes; @@ -20,7 +21,7 @@ public class GetMixByIdQueryHandler(IDatabase database) .SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken); if (mix == null) - return Error.NotFound(description: $"Mix {request.MixId} was not found"); + return AppErrors.NotFound("Mix", request.MixId); var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken); diff --git a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs index c40290d..bcb68b3 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs @@ -1,4 +1,3 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; @@ -7,18 +6,19 @@ using Musify.Application.Mixes.Responses; namespace Musify.Application.Mixes; public record GetMixesByUserIdQuery(long UserId) - : IQuery>>; + : IQuery>; public class GetMixesByUserIdQueryHandler(IDatabase database) - : IQueryHandler>> + : 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) + .ThenBy(mix => mix.Id) .ToListAsync(cancellationToken); var orderedItemsByMix = mixes.ToDictionary( diff --git a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs index 3626d30..7e9184a 100644 --- a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; namespace Musify.Application.PlayLists; @@ -18,18 +19,13 @@ public class AddTrackToPlayListCommandHandler( { public async ValueTask> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken) { - var playList = await database.PlayLists - .AsNoTracking() - .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); - if (playList == null) - return AppErrors.NotFound("PlayList", request.PlayListId); - - if (playList.OwnerUserId != request.UserId) - return AppErrors.Forbidden("PlayList", request.PlayListId); + var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (playList.IsError) + return playList.Errors; var trackExists = await database.Tracks .AsNoTracking() - .AnyAsync(t => t.Id == request.TrackId, cancellationToken); + .AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); if (!trackExists) return AppErrors.NotFound("Track", request.TrackId); diff --git a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index 6b1d66b..76b8e2b 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Application.Events; using Musify.Application.PlayLists.Responses; using Musify.Application.Services; @@ -53,7 +54,7 @@ public class CreatePlayListCommandHandler( { OwnerUserId = request.UserId, Name = request.Name.Trim(), - NormalizedName = request.Name.Trim().ToUpperInvariant(), + NormalizedName = TextNormalizer.Normalize(request.Name), Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), Pictures = resolvedPictures.Pictures, Visibility = request.Visibility @@ -119,7 +120,7 @@ public class CreatePlayListCommandHandler( sizes.Medium, sizes.Large), cancellationToken); - return new Success(); + return Result.Success; } catch (Exception exception) { diff --git a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs index e49b65c..6dad744 100644 --- a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs @@ -1,10 +1,9 @@ using ErrorOr; using Mediator; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -using Musify.Application.Shared; using Musify.Application.Events; +using Musify.Application.Shared; using Musify.Domain.ValueObjects; namespace Musify.Application.PlayLists; @@ -20,43 +19,18 @@ public class DeletePlayListCommandHandler( { public async ValueTask> Handle(DeletePlayListCommand request, CancellationToken cancellationToken) { - 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.OwnerUserId != request.UserId) - { - logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId); - return AppErrors.Forbidden("PlayList", request.PlayListId); - } + var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + var playList = found.Value; playList.LifeCycleStatus = LifeCycleStatus.Removing; - 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}"); - } + await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken); + await database.SaveChangesAsync(cancellationToken); - 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}"); - } + logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId); - return new Success(); + return Result.Success; } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs index 825861d..8cbfdae 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs @@ -3,7 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.PlayLists.Responses; -using Musify.Domain.ValueObjects; +using Musify.Application.Shared; namespace Musify.Application.PlayLists; @@ -15,29 +15,16 @@ public class GetPlayListByIdQueryHandler(IDatabase database) { public async ValueTask> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) { - var entry = await database.PlayLists + var playList = await database.PlayLists .AsNoTracking() - .Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active) - .Select(p => new - { - PlayList = p, - CoverTrackIds = p.PlayListTracks - .OrderBy(plt => plt.Position) - .Take(PlayListApplicationResponse.CoverTrackCount) - .Select(plt => plt.TrackId) - .ToList() - }) + .Active() + .VisibleTo(request.RequestingUserId) + .Where(p => p.Id == request.Id) + .SelectResponse() .SingleOrDefaultAsync(cancellationToken); - if (entry == null) - return Error.NotFound(); - - if (entry.PlayList.Visibility == PlayListVisibility.Private - && entry.PlayList.OwnerUserId != request.RequestingUserId) - { - return Error.NotFound(); - } - - return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds); + return playList == null + ? AppErrors.NotFound("PlayList", request.Id) + : playList; } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index d99ffee..6aa90db 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -2,11 +2,10 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.PlayLists.Responses; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; -using X.PagedList; -using X.PagedList.EF; namespace Musify.Application.PlayLists; @@ -22,33 +21,21 @@ public class GetPlayListTracksQueryHandler(IDatabase database) { public async ValueTask>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) { - var playListExists = await database.PlayLists + var playListVisible = await database.PlayLists .AsNoTracking() - .AnyAsync( - p => p.Id == request.PlayListId - && p.LifeCycleStatus == LifeCycleStatus.Active - && (p.Visibility == PlayListVisibility.Public || p.OwnerUserId == request.ViewerId), - cancellationToken); - if (!playListExists) - return Error.NotFound(); + .Active() + .VisibleTo(request.ViewerId) + .AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken); + if (!playListVisible) + return AppErrors.NotFound("PlayList", request.PlayListId); - var tracksQuery = database.PlayListHasTracks + return await database.PlayListHasTracks .AsNoTracking() - .Include(plt => plt.Track.Owner) - .Include(plt => plt.Track.Tags) - .Where(plt => plt.PlayListId == request.PlayListId && plt.Track.LifeCycleStatus == LifeCycleStatus.Active); - - 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 pagedTracks = new StaticPagedList( - pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedTracks); + .Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .OrderBy(playListTrack => playListTrack.Position) + .ThenBy(playListTrack => playListTrack.Id) + .Select(playListTrack => playListTrack.Track) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs index c9d2e05..6105348 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -1,12 +1,8 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.PlayLists.Responses; using Musify.Application.Shared; -using Musify.Domain.ValueObjects; -using X.PagedList; -using X.PagedList.EF; namespace Musify.Application.PlayLists; @@ -16,45 +12,29 @@ public record GetPlayListsByUserIdQuery( int PageNumber, int PageSize, long? ViewerId = null) - : IQuery>>; + : IQuery>; public class GetPlayListsByUserIdQueryHandler(IDatabase database) - : IQueryHandler>> + : 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.OwnerUserId == request.UserId - && p.LifeCycleStatus == LifeCycleStatus.Active - && (p.Visibility == PlayListVisibility.Public || p.OwnerUserId == request.ViewerId)); + .Active() + .VisibleTo(request.ViewerId) + .Where(playList => playList.OwnerUserId == request.UserId); - if (!string.IsNullOrEmpty(request.Name)) + if (!string.IsNullOrWhiteSpace(request.Name)) { - var normalizedName = request.Name.Trim().ToUpperInvariant(); - playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName)); + var normalizedName = TextNormalizer.Normalize(request.Name); + playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName)); } - var totalCount = await playListsQuery.CountAsync(cancellationToken); - - var pagedEntities = await playListsQuery - .OrderBy(p => p.CreatedAt) - .ThenBy(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); - - return PaginatedResponse.FromPagedList(pagedPlayLists); + return await playListsQuery + .OrderBy(playList => playList.CreatedAt) + .ThenBy(playList => playList.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs index 6f4c9ca..e6c44c2 100644 --- a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs @@ -17,14 +17,9 @@ public class RemoveTrackFromPlayListCommandHandler( { public async ValueTask> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken) { - var playList = await database.PlayLists - .AsNoTracking() - .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); - if (playList == null) - return AppErrors.NotFound("PlayList", request.PlayListId); - - if (playList.OwnerUserId != request.UserId) - return AppErrors.Forbidden("PlayList", request.PlayListId); + var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (playList.IsError) + return playList.Errors; var playListTracks = await database.PlayListHasTracks .Where(plt => plt.PlayListId == request.PlayListId) diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs index 527a324..8b9ddb4 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs @@ -1,6 +1,9 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; +using System.Text.Json.Serialization; +using Musify.Application.Serialization; + namespace Musify.Application.PlayLists.Responses; public record PlayListApplicationResponse( @@ -13,13 +16,18 @@ public record PlayListApplicationResponse( PlayListVisibility Visibility, DateTime CreatedAt, DateTime UpdatedAt, - IReadOnlyList CoverTrackIds) + IReadOnlyList CoverTrackIds, + [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, + int TrackCount, + double DurationSeconds) { - public const int CoverTrackCount = 4; + public const int CoverTrackCount = PlayListProjections.CoverTrackCount; public static PlayListApplicationResponse FromEntity( PlayList playList, - IReadOnlyList? coverTrackIds = null) + IReadOnlyList? coverTrackIds = null, + int trackCount = 0, + double durationSeconds = 0) { return new PlayListApplicationResponse( playList.Id, @@ -31,6 +39,9 @@ public record PlayListApplicationResponse( playList.Visibility, playList.CreatedAt, playList.UpdatedAt, - coverTrackIds ?? []); + coverTrackIds ?? [], + playList.OwnerUserId, + trackCount, + durationSeconds); } } diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs new file mode 100644 index 0000000..9e23757 --- /dev/null +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs @@ -0,0 +1,37 @@ +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.PlayLists.Responses; + +public static class PlayListProjections +{ + public const int CoverTrackCount = 4; + + public static IQueryable Active(this IQueryable query) => + query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active); + + public static IQueryable VisibleTo(this IQueryable query, long? viewerId) => + query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId); + + public static IQueryable SelectResponse(this IQueryable query) => + query.Select(playList => new PlayListApplicationResponse( + playList.Id, + playList.Name, + playList.Description, + playList.Pictures!.SmallName, + playList.Pictures!.MediumName, + playList.Pictures!.LargeName, + playList.Visibility, + playList.CreatedAt, + playList.UpdatedAt, + playList.PlayListTracks + .OrderBy(playListTrack => playListTrack.Position) + .Take(CoverTrackCount) + .Select(playListTrack => playListTrack.TrackId) + .ToList(), + playList.OwnerUserId, + playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active), + playList.PlayListTracks + .Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .Sum(playListTrack => playListTrack.Track.DurationSeconds))); +} diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index 6b329df..53a2dd1 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -50,7 +50,7 @@ public class UpdatePlayListCommandHandler( if (!string.IsNullOrWhiteSpace(request.NewName)) { playListEntity.Name = request.NewName.Trim(); - playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant(); + playListEntity.NormalizedName = TextNormalizer.Normalize(request.NewName); } if (request.NewDescription != null) @@ -123,7 +123,7 @@ public class UpdatePlayListCommandHandler( sizes.Medium, sizes.Large), cancellationToken); - return new Success(); + return Result.Success; } catch (Exception exception) { diff --git a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs index a14bd68..039e57e 100644 --- a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs @@ -4,6 +4,7 @@ using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; +using Musify.Application.Shared; namespace Musify.Application.Services; @@ -30,7 +31,7 @@ public sealed class UploadIntentValidator( if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser) return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire."); - return new Success(); + return Result.Success; } public async Task> ValidateAndLoadAsync( @@ -44,20 +45,20 @@ public sealed class UploadIntentValidator( .FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken); if (intent == null || intent.UserId != userId) - return Error.NotFound(description: "Upload intent not found or not accessible."); + return AppErrors.NotFound("UploadIntent", intentId); if (intent.Purpose != purpose) return Error.Validation(description: "Upload intent was issued for a different purpose."); if (intent.IsConsumed) - return Error.Conflict(description: "Upload intent has already been consumed."); + return AppErrors.Conflict("UploadIntent.Consumed", "Upload intent has already been consumed."); if (intent.IsExpiredAt(DateTime.UtcNow)) return Error.Validation(description: "Upload intent has expired."); var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken); if (metadata == null) - return Error.NotFound(description: "Uploaded object not found in storage. Upload the file first."); + return Error.NotFound("UploadIntent.ObjectMissing", "Uploaded object not found in storage. Upload the file first."); if (metadata.ContentLength > config.MaxUploadBytes) return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes."); diff --git a/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs b/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs new file mode 100644 index 0000000..5ad6072 --- /dev/null +++ b/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs @@ -0,0 +1,28 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Domain.Abstractions; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Shared; + +public static class OwnershipExtensions +{ + /// + /// Loads an active, tracked entity owned by : NotFound when it is missing + /// or not active, Forbidden when someone else owns it. + /// + public static async Task> FindOwnedAsync( + this DbSet set, Guid id, long userId, CancellationToken cancellationToken) + where T : class, IOwnedEntity, IHasLifeCycle + { + var entity = await set.SingleOrDefaultAsync( + e => e.Id == id && e.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); + + if (entity == null) + return AppErrors.NotFound(typeof(T).Name, id); + + return entity.OwnerUserId == userId + ? entity + : AppErrors.Forbidden(typeof(T).Name, id); + } +} diff --git a/backend/Core/Musify.Application/Shared/PaginatedResponse.cs b/backend/Core/Musify.Application/Shared/PaginatedResponse.cs deleted file mode 100644 index e77284b..0000000 --- a/backend/Core/Musify.Application/Shared/PaginatedResponse.cs +++ /dev/null @@ -1,25 +0,0 @@ -using X.PagedList; - -namespace Musify.Application.Shared; - -public record PaginatedResponse( - IReadOnlyCollection Items, - int PageNumber, - int PageSize, - int PageCount, - int TotalItemCount, - bool HasNextPage, - bool HasPreviousPage) -{ - public static PaginatedResponse FromPagedList(IPagedList pagedList) - { - 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/Pagination.cs b/backend/Core/Musify.Application/Shared/Pagination.cs index b32e156..451669e 100644 --- a/backend/Core/Musify.Application/Shared/Pagination.cs +++ b/backend/Core/Musify.Application/Shared/Pagination.cs @@ -1,6 +1,45 @@ +using Microsoft.EntityFrameworkCore; + namespace Musify.Application.Shared; public record PageRequest(int PageNumber = 1, int PageSize = 20) { public const int MaxPageSize = 100; } + +public record PaginatedResponse( + IReadOnlyList Items, + int PageNumber, + int PageSize, + int PageCount, + int TotalItemCount, + bool HasNextPage, + bool HasPreviousPage); + +public static class PaginationExtensions +{ + /// Counts and pages the same (already filtered, ordered and projected) query. + public static async Task> ToPaginatedAsync( + this IQueryable query, PageRequest page, CancellationToken cancellationToken) + { + var pageNumber = Math.Max(page.PageNumber, 1); + var pageSize = Math.Clamp(page.PageSize, 1, PageRequest.MaxPageSize); + + var totalCount = await query.CountAsync(cancellationToken); + var items = await query + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + var pageCount = (int)Math.Ceiling(totalCount / (double)pageSize); + + return new PaginatedResponse( + items, + pageNumber, + pageSize, + pageCount, + totalCount, + HasNextPage: pageNumber < pageCount, + HasPreviousPage: pageNumber > 1 && pageCount > 0); + } +} diff --git a/backend/Core/Musify.Application/Shared/TextNormalizer.cs b/backend/Core/Musify.Application/Shared/TextNormalizer.cs new file mode 100644 index 0000000..e0c3e46 --- /dev/null +++ b/backend/Core/Musify.Application/Shared/TextNormalizer.cs @@ -0,0 +1,6 @@ +namespace Musify.Application.Shared; + +public static class TextNormalizer +{ + public static string Normalize(string value) => value.Trim().ToUpperInvariant(); +} diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index 1f2cfc1..cd4ed2a 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Application.Events; using Musify.Application.Services; using Musify.Application.Tracks.Responses; @@ -82,8 +83,8 @@ public class CreateTrackCommandHandler( var trackEntity = new Track { - Title = request.Title, - NormalizedTitle = request.Title.ToUpperInvariant(), + Title = request.Title.Trim(), + NormalizedTitle = TextNormalizer.Normalize(request.Title), OwnerUserId = user.Id, Owner = user, IsExplicit = request.IsExplicit, diff --git a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs index e4d75bb..bde1aa1 100644 --- a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs +++ b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs @@ -1,10 +1,9 @@ using ErrorOr; using Mediator; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -using Musify.Application.Shared; using Musify.Application.Events; +using Musify.Application.Shared; using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; @@ -22,49 +21,21 @@ public class DeleteTrackCommandHandler( { public async ValueTask> Handle(DeleteTrackCommand request, CancellationToken cancellationToken) { - 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 AppErrors.Forbidden("Track", request.TrackId); - } + var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + var track = found.Value; if (track.Audio.IsInProgress) - { - 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"); - } + return AppErrors.Conflict("Track.Processing", "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}"); - } + await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken); + await database.SaveChangesAsync(cancellationToken); - 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}"); - } + logger.LogInformation("Marked track {TrackId} as removing", request.TrackId); - return new Success(); + return Result.Success; } } diff --git a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs index e73095f..2a44802 100644 --- a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs +++ b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs @@ -2,40 +2,38 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; -using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; -public record GetListeningHistoryQuery(long UserId) : IQuery>; +public record GetListeningHistoryQuery(long UserId) : IQuery>; -public class GetListeningHistoryQueryHandler( - IDatabase database) - : IQueryHandler> +public class GetListeningHistoryQueryHandler(IDatabase database) + : IQueryHandler> { public const int ListSize = 15; - public async ValueTask> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken) + public async ValueTask> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken) { var recentTrackIds = await database.ListeningHistories .AsNoTracking() .Where(l => l.UserId == query.UserId && l.IsCounted) .GroupBy(l => l.TrackId) .OrderByDescending(g => g.Max(l => l.ListenedAt)) + .ThenBy(g => g.Key) .Select(g => g.Key) .Take(ListSize) .ToListAsync(cancellationToken); var tracksById = await database.Tracks .AsNoTracking() - .Include(t => t.Owner) - .Include(t => t.Tags) - .Where(t => recentTrackIds.Contains(t.Id) && t.LifeCycleStatus == LifeCycleStatus.Active) - .Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) }) + .Active() + .Where(t => recentTrackIds.Contains(t.Id)) + .SelectResponse() .ToDictionaryAsync(t => t.Id, cancellationToken); return recentTrackIds .Where(id => tracksById.ContainsKey(id)) .Select(id => tracksById[id]) - .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount)); + .ToList(); } } diff --git a/backend/Core/Musify.Application/Tracks/GetTrackById.cs b/backend/Core/Musify.Application/Tracks/GetTrackById.cs index 7f4a1a1..3614661 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackById.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackById.cs @@ -2,8 +2,8 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; @@ -15,16 +15,15 @@ public class GetTrackByIdQueryHandler(IDatabase database) { 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 && t.LifeCycleStatus == LifeCycleStatus.Active) - .Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) }) + var track = await database.Tracks + .AsNoTracking() + .Active() + .Where(t => t.Id == request.TrackId) + .SelectResponse() .SingleOrDefaultAsync(cancellationToken); - if (entity == null) - return Error.NotFound(); - - return TrackApplicationResponse.FromEntity(entity.Track, entity.ListensCount); + return track == null + ? AppErrors.NotFound("Track", request.TrackId) + : track; } } diff --git a/backend/Core/Musify.Application/Tracks/GetTrackCover.cs b/backend/Core/Musify.Application/Tracks/GetTrackCover.cs index 3e8f602..51a963c 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackCover.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackCover.cs @@ -3,6 +3,9 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Configuration; using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; +using MimeMapping; namespace Musify.Application.Tracks; @@ -20,12 +23,12 @@ public class GetTrackCoverQueryHandler( public async ValueTask> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken) { var track = await database.Tracks.AsNoTracking() - .Where(t => t.Id == request.TrackId) + .Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active) .Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName }) .SingleOrDefaultAsync(cancellationToken); if (track == null) - return Error.NotFound(); + return AppErrors.NotFound("TrackCover", request.TrackId); var routes = trackConfiguration.Routes; var size = PictureSizeParser.Parse(request.Size); @@ -38,13 +41,9 @@ public class GetTrackCoverQueryHandler( var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name); if (string.IsNullOrEmpty(name) || key == null) - return Error.NotFound(); + return AppErrors.NotFound("TrackCover", request.TrackId); - var contentType = name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) - ? "image/webp" - : name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) - ? "image/png" - : "image/jpeg"; + var contentType = MimeUtility.GetMimeMapping(name); return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType); } diff --git a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs b/backend/Core/Musify.Application/Tracks/GetTrackStream.cs index 2204afe..6678931 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackStream.cs @@ -7,6 +7,7 @@ using Musify.Application.Contracts; using Musify.Application.Services; using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; +using Musify.Application.Shared; namespace Musify.Application.Tracks; @@ -32,13 +33,10 @@ public class GetTrackStreamQueryHandler( .SingleOrDefaultAsync(cancellationToken); if (track == null) - return Error.NotFound(); + return AppErrors.NotFound("Track", request.TrackId); 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."); - } + return AppErrors.Conflict("Track.AudioNotReady", "Track audio is not available for streaming yet."); var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken); diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index 6fc1d44..6120e24 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -1,55 +1,37 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; -using X.PagedList.EF; namespace Musify.Application.Tracks; public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null) - : IQuery>; + : IQuery>; public class GetTracksQueryHandler(IDatabase database) - : IQueryHandler> + : IQueryHandler> { - public async ValueTask> Handle(GetTracksQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetTracksQuery request, CancellationToken cancellationToken) { - var tracksQuery = database.UserHasTracks + var tracksQuery = database.Tracks .AsNoTracking() - .Include(ut => ut.Track.Owner) - .Include(ut => ut.Track.Tags) - .Where(ut => ut.Track.LifeCycleStatus == LifeCycleStatus.Active); + .Active(); - if (!string.IsNullOrEmpty(request.Name)) + if (!string.IsNullOrWhiteSpace(request.Name)) { - var normalizedName = request.Name.Trim().ToUpperInvariant(); - tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); + var normalizedName = TextNormalizer.Normalize(request.Name); + tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName)); } if (request.Genre is { } genre) - tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre)); + tracksQuery = tracksQuery.Where(track => 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); + return await tracksQuery + .OrderByDescending(track => track.CreatedAt) + .ThenBy(track => track.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index e03ecc8..5ec82be 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -4,9 +4,6 @@ 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.Domain.ValueObjects; namespace Musify.Application.Tracks; @@ -22,29 +19,21 @@ public class GetTracksByUserIdQueryHandler(IDatabase database) { public async ValueTask>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) { - var tracksQuery = database.UserHasTracks + var tracksQuery = database.Tracks .AsNoTracking() - .Include(ut => ut.Track.Owner) - .Include(ut => ut.Track.Tags) - .Where(p => p.UserId == request.UserId && p.Track.LifeCycleStatus == LifeCycleStatus.Active); + .Active() + .Where(track => track.OwnerUserId == request.UserId); - if (!string.IsNullOrEmpty(request.Name)) + if (!string.IsNullOrWhiteSpace(request.Name)) { - var normalizedName = request.Name.Trim().ToUpperInvariant(); - tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); + var normalizedName = TextNormalizer.Normalize(request.Name); + tracksQuery = tracksQuery.Where(track => 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(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); - - return PaginatedResponse.FromPagedList(pagedTracks); + return await tracksQuery + .OrderByDescending(track => track.CreatedAt) + .ThenBy(track => track.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs index d3a5907..0cc95cc 100644 --- a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs +++ b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs @@ -2,6 +2,7 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.Shared; namespace Musify.Application.Tracks; @@ -24,7 +25,7 @@ public class RecordListeningProgressCommandHandler(IDatabase database) .SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken); if (listen == null) - return Error.NotFound(); + return AppErrors.NotFound("Listen", request.ListenId); var now = DateTime.UtcNow; var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds; diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs new file mode 100644 index 0000000..33fa4f8 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs @@ -0,0 +1,24 @@ +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks.Responses; + +public static class TrackProjections +{ + public static IQueryable Active(this IQueryable query) => + query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active); + + public static IQueryable SelectResponse(this IQueryable query) => + query.Select(track => new TrackApplicationResponse( + track.Id, + track.Title, + track.Owner.Name, + track.Audio.TranscodeStatus, + track.DurationSeconds, + track.ListeningHistories.Count(listen => listen.IsCounted), + track.CreatedAt, + track.UpdatedAt, + track.OwnerUserId, + track.Tags.Select(tag => tag.Tag).ToList(), + track.IsExplicit)); +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs deleted file mode 100644 index 9e7af80..0000000 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Musify.Application.Tracks.Responses; - -public record TrackSearchItemResponse(TrackApplicationResponse Track) -{ - public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => new(track); -} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs deleted file mode 100644 index c454450..0000000 --- a/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Musify.Application.Tracks.Responses; - - -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/FollowUser.cs b/backend/Core/Musify.Application/Users/FollowUser.cs index 345fc89..bcb4441 100644 --- a/backend/Core/Musify.Application/Users/FollowUser.cs +++ b/backend/Core/Musify.Application/Users/FollowUser.cs @@ -24,16 +24,13 @@ public class FollowUserCommandHandler( .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"); - } + return AppErrors.NotFound("User", request.FollowedId); var alreadyFollowing = await database.UserFollows .AsNoTracking() .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); if (alreadyFollowing) - return new Success(); + return Result.Success; await database.UserFollows.AddAsync(new UserFollow { @@ -49,6 +46,6 @@ public class FollowUserCommandHandler( logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId); - return new Success(); + return Result.Success; } } diff --git a/backend/Core/Musify.Application/Users/GetListeningStats.cs b/backend/Core/Musify.Application/Users/GetListeningStats.cs index a54880a..a1fcd1d 100644 --- a/backend/Core/Musify.Application/Users/GetListeningStats.cs +++ b/backend/Core/Musify.Application/Users/GetListeningStats.cs @@ -1,13 +1,12 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.Users.Responses; 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 { diff --git a/backend/Core/Musify.Application/Users/GetUserById.cs b/backend/Core/Musify.Application/Users/GetUserById.cs index 989cb04..3069355 100644 --- a/backend/Core/Musify.Application/Users/GetUserById.cs +++ b/backend/Core/Musify.Application/Users/GetUserById.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Users.Responses; +using Musify.Application.Shared; namespace Musify.Application.Users; @@ -20,9 +21,8 @@ public class GetUserByIdQueryHandler(IDatabase database) .Select(u => UserApplicationResponse.FromEntity(u)) .SingleOrDefaultAsync(cancellationToken); - if (user == null) - return Error.NotFound(); - - return user; + return user == null + ? AppErrors.NotFound("User", request.Id) + : user; } } diff --git a/backend/Core/Musify.Application/Users/GetUserFollowers.cs b/backend/Core/Musify.Application/Users/GetUserFollowers.cs index 8f280e1..90284fb 100644 --- a/backend/Core/Musify.Application/Users/GetUserFollowers.cs +++ b/backend/Core/Musify.Application/Users/GetUserFollowers.cs @@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -using X.PagedList.EF; namespace Musify.Application.Users; @@ -17,20 +16,18 @@ public class GetUserFollowersQueryHandler(IDatabase database) public async ValueTask>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken) { if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) - return Error.NotFound(); + return AppErrors.NotFound("User", request.UserId); if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken)) - return Error.Forbidden(); + return AppErrors.Forbidden("User", request.UserId); - var followsQuery = database.UserFollows - .AsNoTracking() - .Where(f => f.FollowedId == request.UserId); - - var totalCount = await followsQuery.CountAsync(cancellationToken); var viewerId = request.ViewerId; - var page = await followsQuery + return await database.UserFollows + .AsNoTracking() + .Where(f => f.FollowedId == request.UserId) .OrderByDescending(f => f.CreatedAt) + .ThenBy(f => f.Id) .Select(f => new UserSummaryResponse( f.Follower.Id, f.Follower.Name, @@ -38,8 +35,6 @@ public class GetUserFollowersQueryHandler(IDatabase database) 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); + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Users/GetUserFollowing.cs b/backend/Core/Musify.Application/Users/GetUserFollowing.cs index 89279fa..50c7c1b 100644 --- a/backend/Core/Musify.Application/Users/GetUserFollowing.cs +++ b/backend/Core/Musify.Application/Users/GetUserFollowing.cs @@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -using X.PagedList.EF; namespace Musify.Application.Users; @@ -17,17 +16,15 @@ public class GetUserFollowingQueryHandler(IDatabase database) public async ValueTask>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken) { if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) - return Error.NotFound(); + return AppErrors.NotFound("User", request.UserId); - var followsQuery = database.UserFollows - .AsNoTracking() - .Where(f => f.FollowerId == request.UserId); - - var totalCount = await followsQuery.CountAsync(cancellationToken); var viewerId = request.ViewerId; - var page = await followsQuery + return await database.UserFollows + .AsNoTracking() + .Where(f => f.FollowerId == request.UserId) .OrderByDescending(f => f.CreatedAt) + .ThenBy(f => f.Id) .Select(f => new UserSummaryResponse( f.Followed.Id, f.Followed.Name, @@ -35,8 +32,6 @@ public class GetUserFollowingQueryHandler(IDatabase database) 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); + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Users/GetUserProfile.cs b/backend/Core/Musify.Application/Users/GetUserProfile.cs index 88039db..cf9796e 100644 --- a/backend/Core/Musify.Application/Users/GetUserProfile.cs +++ b/backend/Core/Musify.Application/Users/GetUserProfile.cs @@ -1,26 +1,14 @@ -using System.Text.Json.Serialization; using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; -using Musify.Application.Serialization; +using Musify.Application.Shared; +using Musify.Application.Users.Responses; 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> { @@ -41,7 +29,7 @@ public class GetUserProfileQueryHandler(IDatabase database) .SingleOrDefaultAsync(cancellationToken); if (user == null) - return Error.NotFound(); + return AppErrors.NotFound("User", request.UserId); var followersCount = await database.UserFollows .AsNoTracking() diff --git a/backend/Core/Musify.Application/Users/GetUsers.cs b/backend/Core/Musify.Application/Users/GetUsers.cs index cbe48bb..1dcffd7 100644 --- a/backend/Core/Musify.Application/Users/GetUsers.cs +++ b/backend/Core/Musify.Application/Users/GetUsers.cs @@ -1,43 +1,40 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -using X.PagedList.EF; namespace Musify.Application.Users; public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null) - : IQuery>>; + : IQuery>; public class GetUsersQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler> { - public async ValueTask>> Handle(GetUsersQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetUsersQuery request, CancellationToken cancellationToken) { var usersQuery = database.Users .AsNoTracking(); - if (!string.IsNullOrEmpty(request.UsernameSearch)) + if (!string.IsNullOrWhiteSpace(request.UsernameSearch)) { - var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant(); - usersQuery = usersQuery.Where(u => u.NormalizedName.Contains(normalizedUsername)); + var normalizedUsername = TextNormalizer.Normalize(request.UsernameSearch); + usersQuery = usersQuery.Where(user => user.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); + return await usersQuery + .OrderBy(user => user.Name) + .ThenBy(user => user.Id) + .Select(user => new UserSummaryResponse( + user.Id, + user.Name, + user.FirstName, + user.SecondName, + user.ProfilePictureUrl, + viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == user.Id))) + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs b/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs new file mode 100644 index 0000000..c12d7ce --- /dev/null +++ b/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs @@ -0,0 +1,3 @@ +namespace Musify.Application.Users.Responses; + +public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays); diff --git a/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs new file mode 100644 index 0000000..b1d9520 --- /dev/null +++ b/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using Musify.Application.Serialization; + +namespace Musify.Application.Users.Responses; + +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); diff --git a/backend/Core/Musify.Application/Users/SyncUser.cs b/backend/Core/Musify.Application/Users/SyncUser.cs index 2d94958..ff5c6ac 100644 --- a/backend/Core/Musify.Application/Users/SyncUser.cs +++ b/backend/Core/Musify.Application/Users/SyncUser.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Domain.Entities; namespace Musify.Application.Users; @@ -29,7 +30,7 @@ public class SyncUserCommandHandler(IDatabase database, ILogger 0) logger.LogInformation("Synced profile for user {UserId}", request.Id); - return new Success(); + return Result.Success; } } diff --git a/backend/Core/Musify.Application/Users/UnfollowUser.cs b/backend/Core/Musify.Application/Users/UnfollowUser.cs index 432d3b2..49c24d6 100644 --- a/backend/Core/Musify.Application/Users/UnfollowUser.cs +++ b/backend/Core/Musify.Application/Users/UnfollowUser.cs @@ -19,13 +19,13 @@ public class UnfollowUserCommandHandler( .SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); if (follow == null) - return new Success(); + return Result.Success; database.UserFollows.Remove(follow); await database.SaveChangesAsync(cancellationToken); logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId); - return new Success(); + return Result.Success; } } diff --git a/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs b/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs index bb56244..d65c9fb 100644 --- a/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs +++ b/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs @@ -2,7 +2,5 @@ namespace Musify.Domain.Abstractions; public interface IOwnedEntity { - public Guid Id { get; } - public long OwnerUserId { get; } } diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index aa0fa86..eb7442c 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -45,8 +45,6 @@ - - diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index 8798d78..d75a5b5 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -25,7 +25,7 @@ public static class AlbumEndpoints .AddEndpointFilter>() .WithName("GetAlbums") .WithSummary("Get Paginated Albums, Optionally Filtered By Title.") - .Produces(); + .Produces>(); group.MapGet("/{id}", GetAlbumById) .WithName("GetAlbumById") @@ -129,7 +129,7 @@ public static class AlbumEndpoints [AsParameters] PageQuery page) { var result = await mediator.Send(new GetAlbumsQuery(title, page.PageNumber, page.PageSize), cancellationToken); - return result.ToHttpResult(); + return Results.Ok(result); } private static async Task GetAlbumById( @@ -166,7 +166,7 @@ public static class AlbumEndpoints } var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken); - return result.ToHttpResult(); + return Results.Ok(result); } private static async Task GetAlbumTracks( diff --git a/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs index da31c5b..382655b 100644 --- a/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs @@ -1,5 +1,6 @@ using Mediator; using Musify.Application.Genres; +using Musify.Application.Genres.Responses; namespace Musify.Api.Endpoints; diff --git a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs index 4c74e53..22f747e 100644 --- a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs @@ -42,7 +42,7 @@ public static class LikeEndpoints [AsParameters] PageQuery page) { var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken); - return result.ToHttpResult(); + return Results.Ok(result); } private static async Task ToggleTrackLike( diff --git a/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs index 41a79cd..371cbde 100644 --- a/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs @@ -36,7 +36,7 @@ public static class MixEndpoints CancellationToken cancellationToken) { var result = await mediator.Send(new GetMixesByUserIdQuery(currentUser.RequiredId), cancellationToken); - return result.ToHttpResult(); + return Results.Ok(result); } private static async Task GetMixById( diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index dd57a99..d3daa30 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -126,7 +126,7 @@ public static class PlayListEndpoints [AsParameters] PageQuery page) { var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); - return result.ToHttpResult(); + return Results.Ok(result); } private static async Task GetPlayListTracks( diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index dcbf00b..a718e6d 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -23,7 +23,7 @@ public static class TrackEndpoints .AddEndpointFilter>() .WithName("GetTracks") .WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.") - .Produces() + .Produces>() .ProducesValidationProblem(); group.MapGet("/{id}", GetTrackById) @@ -116,7 +116,7 @@ public static class TrackEndpoints } var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, parsedGenre), cancellationToken); - return result.ToHttpResult(); + return Results.Ok(result); } private static async Task GetTrackById( diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs index af38b56..5744d64 100644 --- a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -105,7 +105,7 @@ public static class UserEndpoints [AsParameters] PageQuery page) { var result = await mediator.Send(new GetUsersQuery(page.PageNumber, page.PageSize, usernameSearch, currentUser.Id), cancellationToken); - return result.ToHttpResult(); + return Results.Ok(result); } private static async Task GetUserById( diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs index 3292381..b8790cb 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs @@ -4,7 +4,8 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using Musify.Api.Tests.TestSupport; -using Musify.Application.Genres; +using Musify.Application.Genres.Responses; +using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; @@ -85,8 +86,8 @@ public sealed class GenreEndpointsTests(ApiTestFixture fixture) 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); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!.Items, item => item.Title == title); } [Fact] @@ -98,8 +99,8 @@ public sealed class GenreEndpointsTests(ApiTestFixture fixture) 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)); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!.Items, item => item.Title == title); + Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Tags)); } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs index 458ab85..b0df965 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs @@ -15,10 +15,8 @@ public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase 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); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - Assert.All(result.Value.Items, item => Assert.NotNull(item.Album)); + Assert.Equal(2, result.TotalItemCount); + Assert.All(result.Items, item => Assert.NotNull(item)); } [Fact] @@ -28,10 +26,8 @@ public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase 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); - - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.Equal("Rock Classics", item.Album.Title); + var item = Assert.Single(result.Items); + Assert.Equal("Rock Classics", item.Title); } [Fact] @@ -41,9 +37,7 @@ public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase await SeedAsync(owner, TestEntities.Album(owner.Id, "One"), TestEntities.Album(owner.Id, "Two"), TestEntities.Album(owner.Id, "Three")); var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.Items.Count); - Assert.True(result.Value.HasNextPage); + Assert.Equal(2, result.Items.Count); + Assert.True(result.HasNextPage); } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs index 080d994..df36f5b 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs @@ -25,9 +25,7 @@ public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase TestEntities.ListeningHistory(owner.Id, newerTrack.Id, DateTime.UtcNow.AddDays(-1))); 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.Equal(["Newer", "Older"], result.Select(album => album.Title)); } [Fact] @@ -37,9 +35,7 @@ public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase await SeedAsync(owner); var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value); + Assert.Empty(result); } [Fact] @@ -53,9 +49,7 @@ public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]); 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.Equal(2, result.Count); } [Fact] @@ -75,6 +69,6 @@ public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase 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.Select(album => album.Title)); } } diff --git a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs index bba8f87..29fd50d 100644 --- a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs @@ -1,6 +1,7 @@ using Musify.Application.Genres; using Musify.Domain.ValueObjects; using Xunit; +using Musify.Application.Genres.Responses; namespace Musify.Application.Tests.Genres; diff --git a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs index ae6a239..9234f5b 100644 --- a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs @@ -21,9 +21,7 @@ public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase 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); - - Assert.False(result.IsError); - Assert.Equal(["Newer like", "Older like"], result.Value.Items.Select(track => track.Title)); + Assert.Equal(["Newer like", "Older like"], result.Items.Select(track => track.Title)); } [Fact] @@ -33,9 +31,7 @@ public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase await SeedAsync(owner); var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value.Items); + Assert.Empty(result.Items); } [Fact] @@ -47,8 +43,6 @@ public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase await SeedAsync(owner, stranger, track, new TrackLike { UserId = stranger.Id, TrackId = track.Id }); var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value.Items); + Assert.Empty(result.Items); } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs index 9533607..9a20c90 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs @@ -19,9 +19,7 @@ public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase await SeedAsync(owner, other, second, first, theirs); 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.Equal(["First", "Second"], result.Select(mix => mix.Title)); } [Fact] @@ -31,8 +29,6 @@ public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase await SeedAsync(owner); var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value); + Assert.Empty(result); } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs index 5ffe334..76b0a75 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs @@ -21,9 +21,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase TestEntities.PlayList(other.Id, "Theirs")); var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var playList = Assert.Single(result.Value.Items); + var playList = Assert.Single(result.Items); Assert.Equal("Mine", playList.Name); } @@ -37,9 +35,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private)); var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var playList = Assert.Single(result.Value.Items); + var playList = Assert.Single(result.Items); Assert.Equal("Public one", playList.Name); } @@ -53,9 +49,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private)); var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.Items.Count); + Assert.Equal(2, result.Items.Count); } [Fact] @@ -65,9 +59,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase await SeedAsync(owner, TestEntities.PlayList(owner.Id, "Road Trip"), TestEntities.PlayList(owner.Id, "Study Focus")); var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var playList = Assert.Single(result.Value.Items); + var playList = Assert.Single(result.Items); Assert.Equal("Road Trip", playList.Name); } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs index 869ab14..88a12c1 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs @@ -21,10 +21,8 @@ public sealed class GetTracksQueryHandlerTests : HandlerTestBase 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); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - Assert.Equal(2, result.Value.Items.Count); + Assert.Equal(2, result.TotalItemCount); + Assert.Equal(2, result.Items.Count); } [Fact] @@ -39,10 +37,8 @@ public sealed class GetTracksQueryHandlerTests : HandlerTestBase 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); - - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.Equal("Bohemian Rhapsody", item.Track.Title); + var item = Assert.Single(result.Items); + Assert.Equal("Bohemian Rhapsody", item.Title); } [Fact] @@ -57,9 +53,7 @@ public sealed class GetTracksQueryHandlerTests : HandlerTestBase 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); - - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.Equal("Loud", item.Track.Title); + var item = Assert.Single(result.Items); + Assert.Equal("Loud", item.Title); } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs index fe1dc64..9734a7b 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs @@ -15,9 +15,7 @@ public sealed class GetUsersQueryHandlerTests : HandlerTestBase 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); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); + Assert.Equal(2, result.TotalItemCount); } [Fact] @@ -26,9 +24,7 @@ public sealed class GetUsersQueryHandlerTests : HandlerTestBase 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); - - Assert.False(result.IsError); - var user = Assert.Single(result.Value.Items); + var user = Assert.Single(result.Items); Assert.Equal(1, user.Id); } @@ -40,8 +36,6 @@ public sealed class GetUsersQueryHandlerTests : HandlerTestBase new UserFollow { FollowerId = 1, FollowedId = 2 }); var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.All(result.Value.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer)); + Assert.All(result.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer)); } } diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 57fb27d..6e889eb 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -763,22 +763,6 @@ export interface components { expiresInSeconds: number | string; uploadUrl: string; }; - AlbumSearchItemResponse: { - album: components["schemas"]["AlbumApplicationResponse"]; - }; - AlbumsSearchResponse: { - items: components["schemas"]["AlbumSearchItemResponse"][]; - /** Format: int32 */ - pageNumber: number | string; - /** Format: int32 */ - pageSize: number | string; - /** Format: int32 */ - pageCount: number | string; - /** Format: int32 */ - totalItemCount: number | string; - hasPreviousPage: boolean; - hasNextPage: boolean; - }; AvailableGenreResponse: { genre: components["schemas"]["Genre"]; incompatibleWith: components["schemas"]["Genre"][]; @@ -796,7 +780,7 @@ export interface components { description: null | string; /** Format: uuid */ pictureIntentId: null | string; - visibility?: components["schemas"]["PlaylistVisibility"]; + visibility?: components["schemas"]["PlayListVisibility"]; }; CreateTrackRequest: { title: string; @@ -918,12 +902,17 @@ export interface components { smallImageKeyName: null | string; mediumImageKeyName: null | string; largeImageKeyName: null | string; - visibility: components["schemas"]["PlaylistVisibility"]; + visibility: components["schemas"]["PlayListVisibility"]; /** Format: date-time */ createdAt: string; /** Format: date-time */ updatedAt: string; coverTrackIds: string[]; + ownerUserId: string; + /** Format: int32 */ + trackCount: number | string; + /** Format: double */ + durationSeconds: number | string; }; PlayListPictureUploadResponse: { /** Format: uuid */ @@ -937,7 +926,7 @@ export interface components { uploadUrl: string; }; /** @enum {unknown} */ - PlaylistVisibility: "Private" | "Public"; + PlayListVisibility: "Private" | "Public"; /** @enum {unknown} */ ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed"; RecordListeningProgressRequest: { @@ -988,22 +977,6 @@ export interface components { tags: components["schemas"]["Genre"][]; isExplicit: boolean; }; - TrackSearchItemResponse: { - track: components["schemas"]["TrackApplicationResponse"]; - }; - TracksSearchResponse: { - items: components["schemas"]["TrackSearchItemResponse"][]; - /** Format: int32 */ - pageNumber: number | string; - /** Format: int32 */ - pageSize: number | string; - /** Format: int32 */ - pageCount: number | string; - /** Format: int32 */ - totalItemCount: number | string; - hasPreviousPage: boolean; - hasNextPage: boolean; - }; TrackStreamResponse: { manifestUrl: string; ticket: string; @@ -1042,7 +1015,7 @@ export interface components { newDescription: null | string; /** Format: uuid */ newPictureIntentId: null | string; - newVisibility?: null | components["schemas"]["PlaylistVisibility"]; + newVisibility?: null | components["schemas"]["PlayListVisibility"]; }; UserApplicationResponse: { id: string; @@ -1106,7 +1079,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlbumsSearchResponse"]; + "application/json": components["schemas"]["PaginatedResponseOfAlbumApplicationResponse"]; }; }; }; @@ -2158,7 +2131,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TracksSearchResponse"]; + "application/json": components["schemas"]["PaginatedResponseOfTrackApplicationResponse"]; }; }; /** @description Bad Request */ diff --git a/web-player/src/routes/(app)/explore/+page.server.ts b/web-player/src/routes/(app)/explore/+page.server.ts index 9cb30d2..3f4f24f 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -63,7 +63,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { genre, genres, tracks, - albums: (albumsRes?.data?.items ?? []).map((item) => item.album), + albums: albumsRes?.data?.items ?? [], users: usersRes?.data?.items ?? [], viewerId: user?.sub ?? null, playlists: playlistsRes?.data?.items ?? [] diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index de92dc7..0b1f5c7 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -49,7 +49,7 @@ let albumMenu = $state(null); let sfilter = $state('Todo'); - type LocalTrack = (typeof data.tracks.items)[number]['track']; + type LocalTrack = (typeof data.tracks.items)[number]; let localItems = $state([]); let localPage = $state(1); @@ -60,7 +60,7 @@ trackMenu.close(); albumMenu = null; sfilter = data.genre ? 'Canciones' : 'Todo'; - localItems = data.tracks.items.map((item) => item.track); + localItems = data.tracks.items; localPage = Number(data.tracks.pageNumber); hasMore = data.tracks.hasNextPage; }); @@ -177,11 +177,7 @@ const res = await fetch(`/api/tracks?${params}`); if (!res.ok) throw new Error(String(res.status)); const next = (await res.json()) as typeof data.tracks; - localItems = appendUnique( - localItems, - next.items.map((item) => item.track), - (track) => track.id - ); + localItems = appendUnique(localItems, next.items, (track) => track.id); localPage = Number(next.pageNumber); hasMore = next.hasNextPage; } catch { diff --git a/web-player/src/routes/+page.server.ts b/web-player/src/routes/+page.server.ts index ab745b2..791961b 100644 --- a/web-player/src/routes/+page.server.ts +++ b/web-player/src/routes/+page.server.ts @@ -40,7 +40,7 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { recentlyPlayedPromise ]); const playlistItems = playlists.data?.items ?? []; - const latestItems = (latest.data?.items ?? []).map((item) => item.track); + const latestItems = latest.data?.items ?? []; const spotlightPlaylist = playlistItems[0] ?? null; const spotlightTracksPromise = spotlightPlaylist From 75d0c0817e43b68f7876cd28f47b7094f8996dd4 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 12:04:54 +0200 Subject: [PATCH 09/21] Deduplicate picture flows: shared upload issuer, cover query and picture source change --- .../Musify.Application/Albums/CreateAlbum.cs | 71 ++------- .../Albums/GetAlbumCover.cs | 57 -------- .../Albums/RequestAlbumPictureUpload.cs | 96 +----------- .../Musify.Application/Albums/UpdateAlbum.cs | 104 +++---------- .../ApplicationDependencyInjection.cs | 1 + .../Events/CreateAlbumResourcesEvent.cs | 4 +- .../Events/CreatePlayListResourcesEvent.cs | 4 +- .../Events/CreateTrackResourcesEvent.cs | 4 +- .../Events/UpdateAlbumPictureEvent.cs | 4 +- .../Events/UpdateAlbumPictureSourceEvent.cs | 4 +- .../Events/UpdatePlayListPictureEvent.cs | 4 +- .../UpdatePlayListPictureSourceEvent.cs | 4 +- .../Events/UpdateTrackPictureEvent.cs | 4 +- .../Musify.Application/Pictures/GetCover.cs | 75 ++++++++++ .../Pictures/PictureSourceChange.cs | 35 +++++ .../Responses/PictureUploadResponse.cs} | 4 +- .../PlayLists/CreatePlayList.cs | 103 ++++--------- .../PlayLists/GetPlayListCover.cs | 57 -------- .../PlayLists/RequestPlayListPictureUpload.cs | 96 +----------- .../PlayListPictureUploadResponse.cs | 10 -- .../PlayLists/UpdatePlayList.cs | 118 ++++----------- .../Services/UploadIntentIssuer.cs | 133 +++++++++++++++++ .../Services/UploadIntentValidator.cs | 31 ++-- .../Musify.Application/Tracks/CreateTrack.cs | 54 ++----- .../Tracks/GetTrackCover.cs | 50 ------- .../Tracks/RequestTrackUploadUrls.cs | 137 ++++-------------- ...lishAlbumPictureProcessingEventActivity.cs | 4 +- ...hPlayListPictureProcessingEventActivity.cs | 4 +- .../PublishTrackProcessingEventsActivity.cs | 4 +- ...ishAlbumPictureProcessingEventArguments.cs | 4 +- ...PlayListPictureProcessingEventArguments.cs | 4 +- .../PublishTrackProcessingEventsArguments.cs | 4 +- .../AlbumPictureSourceRoutingSlipBuilder.cs | 16 +- .../Builders/CreateTrackRoutingSlipBuilder.cs | 4 +- .../PictureWorkflowRoutingSlipBuilder.cs | 22 +-- ...PlayListPictureSourceRoutingSlipBuilder.cs | 16 +- .../Musify.Api/Endpoints/AlbumEndpoints.cs | 18 +-- .../Musify.Api/Endpoints/CoverEndpoint.cs | 31 ++++ .../Musify.Api/Endpoints/PlayListEndpoints.cs | 18 +-- .../Musify.Api/Endpoints/TrackEndpoints.cs | 15 +- .../Endpoints/AlbumEndpointsTests.cs | 3 +- .../Albums/CreateAlbumCommandHandlerTests.cs | 6 +- .../Albums/GetAlbumCoverQueryHandlerTests.cs | 52 ------- ...stAlbumPictureUploadCommandHandlerTests.cs | 17 ++- .../Albums/UpdateAlbumCommandHandlerTests.cs | 6 +- .../Pictures/GetCoverQueryHandlerTests.cs | 104 +++++++++++++ .../CreatePlayListCommandHandlerTests.cs | 6 +- .../GetPlayListCoverQueryHandlerTests.cs | 50 ------- ...layListPictureUploadCommandHandlerTests.cs | 17 ++- .../UpdatePlayListCommandHandlerTests.cs | 6 +- .../Services/UploadIntentValidatorTests.cs | 26 ++-- .../Tracks/CreateTrackCommandHandlerTests.cs | 6 +- .../Tracks/GetTrackCoverQueryHandlerTests.cs | 51 ------- ...questTrackUploadUrlsCommandHandlerTests.cs | 15 +- web-player/src/lib/api/schema.d.ts | 37 ++--- 55 files changed, 667 insertions(+), 1163 deletions(-) delete mode 100644 backend/Core/Musify.Application/Albums/GetAlbumCover.cs create mode 100644 backend/Core/Musify.Application/Pictures/GetCover.cs create mode 100644 backend/Core/Musify.Application/Pictures/PictureSourceChange.cs rename backend/Core/Musify.Application/{Albums/Responses/AlbumPictureUploadResponse.cs => Pictures/Responses/PictureUploadResponse.cs} (63%) delete mode 100644 backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs delete mode 100644 backend/Core/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs create mode 100644 backend/Core/Musify.Application/Services/UploadIntentIssuer.cs delete mode 100644 backend/Core/Musify.Application/Tracks/GetTrackCover.cs create mode 100644 backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs delete mode 100644 backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs delete mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs delete mode 100644 backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index 5c37244..de98485 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -5,9 +5,10 @@ using Microsoft.Extensions.Logging; using Musify.Application.Albums.Responses; using Musify.Application.Configuration; using Musify.Application.Contracts; -using Musify.Application.Shared; using Musify.Application.Events; +using Musify.Application.Pictures; using Musify.Application.Services; +using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; @@ -26,9 +27,7 @@ public class CreateAlbumCommandHandler( IDatabase database, UploadIntentValidator uploadIntentValidator, ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - AlbumConfiguration albumConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) + AlbumConfiguration albumConfiguration) : ICommandHandler> { public async ValueTask> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) @@ -37,17 +36,14 @@ public class CreateAlbumCommandHandler( .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"); - } + return AppErrors.NotFound("User", request.UserId); - var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, cancellationToken); - if (pictureIntent.IsError) - return pictureIntent.Errors; + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; - var intent = pictureIntent.Value; + var picture = prepared.Value; var title = request.Title.Trim(); var album = new Album @@ -57,57 +53,20 @@ public class CreateAlbumCommandHandler( NormalizedTitle = TextNormalizer.Normalize(title), Description = request.Description, ReleaseYear = request.ReleaseYear, - Pictures = EntityPictures.Pending(intent.ObjectName) + Pictures = picture.Pictures }; 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; + await eventBus.PublishAsync( + new CreateAlbumResourcesEvent( + album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), + cancellationToken); - 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}"); - } + await database.SaveChangesAsync(cancellationToken); 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 - { - var sizes = albumConfiguration.PicturesSizes.ToImageSizes(albumConfiguration.Routes); - await eventBus.PublishAsync( - new CreateAlbumResourcesEvent( - albumId, - pictureIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - sizes.Small, - sizes.Medium, - sizes.Large), - cancellationToken); - return Result.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/GetAlbumCover.cs b/backend/Core/Musify.Application/Albums/GetAlbumCover.cs deleted file mode 100644 index b9ca892..0000000 --- a/backend/Core/Musify.Application/Albums/GetAlbumCover.cs +++ /dev/null @@ -1,57 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using MimeMapping; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Application.Shared; -using Musify.Domain.ValueObjects; - -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 async ValueTask> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken) - { - var owned = await database.Albums.AsNoTracking() - .Where(e => e.Id == request.AlbumId && e.LifeCycleStatus == LifeCycleStatus.Active) - .Select(e => new { OwnerUserId = e.OwnerUserId, e.Pictures }) - .SingleOrDefaultAsync(cancellationToken); - - if (owned?.Pictures == null) - return AppErrors.NotFound("AlbumCover", request.AlbumId); - - var pictures = owned.Pictures; - var routes = albumConfiguration.Routes; - var size = PictureSizeParser.Parse(request.Size); - var name = size switch - { - PictureSize.Small => pictures.SmallName, - PictureSize.Large => pictures.LargeName, - _ => pictures.MediumName - }; - var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name); - - // The resized files only exist once processing has finished; until then serve the original. - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) - { - name = pictures.OriginalName; - key = string.IsNullOrEmpty(name) ? null : routes.BuildOriginalPicturePath(owned.OwnerUserId, name); - } - - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) - return AppErrors.NotFound("AlbumCover", request.AlbumId); - - var contentType = MimeUtility.GetMimeMapping(name); - return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType); - } -} diff --git a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs index b689b71..14fdd71 100644 --- a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs +++ b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs @@ -1,12 +1,8 @@ using ErrorOr; using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Albums.Responses; using Musify.Application.Configuration; -using Musify.Application.Contracts; +using Musify.Application.Pictures.Responses; using Musify.Application.Services; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -16,89 +12,13 @@ public record RequestAlbumPictureUploadCommand( string FileType, string ContentType, long? ExpectedSizeBytes = null) - : ICommand>; + : ICommand>; -public class RequestAlbumPictureUploadCommandHandler( - IDatabase database, - UploadIntentValidator uploadIntentValidator, - IStorageService storageService, - ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - AlbumConfiguration albumConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : ICommandHandler> +public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration) + : ICommandHandler> { - public async ValueTask> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) - { - 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.BuildTempPath( - uploadIntentConfiguration.TempRootPrefix, 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} failed upload intent quota check", request.UserId); - return quotaCheck.Errors; - } - - 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"); - } - } + public async ValueTask> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) => + await issuer.IssuePictureAsync( + request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, + request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); } diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index 4d58142..b4d798d 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -6,9 +6,9 @@ using Musify.Application.Albums.Responses; using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Events; +using Musify.Application.Pictures; using Musify.Application.Services; using Musify.Application.Shared; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -27,27 +27,16 @@ public class UpdateAlbumCommandHandler( IDatabase database, UploadIntentValidator uploadIntentValidator, ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - AlbumConfiguration albumConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) + AlbumConfiguration albumConfiguration) : ICommandHandler> { public async ValueTask> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) { - 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 AppErrors.Forbidden("Album", request.AlbumId); - } + var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + var album = found.Value; var title = request.Title.Trim(); album.Title = title; @@ -55,75 +44,30 @@ public class UpdateAlbumCommandHandler( album.Description = request.Description; album.ReleaseYear = request.ReleaseYear; - if (request.NewPictureIntentId.HasValue) + if (request.NewPictureIntentId is { } intentId) { - var validation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, - request.NewPictureIntentId.Value, request.UserId, UploadIntentPurpose.AlbumPicture, cancellationToken); - if (validation.IsError) - return validation.Errors; + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; - var pictureIntent = validation.Value; - album.Pictures = EntityPictures.Pending(pictureIntent.ObjectName); - var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); + var picture = prepared.Value; + album.Pictures = picture.Pictures; - 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 - { - var sizes = albumConfiguration.PicturesSizes.ToImageSizes(albumConfiguration.Routes); await eventBus.PublishAsync( new UpdateAlbumPictureSourceEvent( - albumId, - pictureIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - sizes.Small, - sizes.Medium, - sizes.Large), + album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), cancellationToken); - return Result.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}"); } + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Updated album {AlbumId}", album.Id); + + return await database.Albums + .AsNoTracking() + .Where(a => a.Id == album.Id) + .SelectResponse() + .SingleAsync(cancellationToken); } } diff --git a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs index b1fb216..8e22f82 100644 --- a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs +++ b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs @@ -11,6 +11,7 @@ public static class ApplicationDependencyInjection { services.AddMediator(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddValidatedOptions(configuration); diff --git a/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs index 18290e1..01dada3 100644 --- a/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs @@ -8,6 +8,4 @@ public record CreateAlbumResourcesEvent( string Bucket, string PictureSourceKey, string PictureDestinationKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); diff --git a/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs index b26409c..9ad6955 100644 --- a/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs @@ -8,6 +8,4 @@ public record CreatePlayListResourcesEvent( string Bucket, string PictureSourceKey, string PictureDestinationKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); diff --git a/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs index da92b47..baa9315 100644 --- a/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs @@ -12,6 +12,4 @@ public record CreateTrackResourcesEvent( string AudioSourceKey, string AudioDestinationKey, string AudioProcessedFolderKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); diff --git a/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs index 35324bb..6c8f43e 100644 --- a/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs @@ -6,6 +6,4 @@ public record UpdateAlbumPictureEvent( Guid AlbumId, string Bucket, string SourceKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); diff --git a/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs b/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs index 72a7c52..8fa33b8 100644 --- a/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs @@ -8,6 +8,4 @@ public record UpdateAlbumPictureSourceEvent( string Bucket, string PictureSourceKey, string PictureDestinationKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); diff --git a/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs index 93eb1fd..1b2d646 100644 --- a/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs @@ -6,6 +6,4 @@ public record UpdatePlayListPictureEvent( Guid PlayListId, string Bucket, string SourceKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); diff --git a/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs b/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs index b3c0f61..bba5c34 100644 --- a/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs @@ -8,6 +8,4 @@ public record UpdatePlayListPictureSourceEvent( string Bucket, string PictureSourceKey, string PictureDestinationKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); diff --git a/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs index bab9787..0c83c9e 100644 --- a/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs @@ -6,6 +6,4 @@ public record UpdateTrackPictureEvent( Guid TrackId, string Bucket, string SourceKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); diff --git a/backend/Core/Musify.Application/Pictures/GetCover.cs b/backend/Core/Musify.Application/Pictures/GetCover.cs new file mode 100644 index 0000000..6e91e2d --- /dev/null +++ b/backend/Core/Musify.Application/Pictures/GetCover.cs @@ -0,0 +1,75 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using MimeMapping; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Pictures; + +public enum CoverOwner +{ + Track, + Album, + PlayList +} + +public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium) + : IQuery>; + +public record CoverLocation(string Bucket, string Key, string ContentType); + +public class GetCoverQueryHandler( + IDatabase database, + ApplicationStorageConfiguration storageConfiguration, + TrackConfiguration trackConfiguration, + AlbumConfiguration albumConfiguration, + PlayListConfiguration playListConfiguration) + : IQueryHandler> +{ + private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large); + + public async ValueTask> Handle(GetCoverQuery request, CancellationToken cancellationToken) + { + var (source, routes) = request.Owner switch + { + CoverOwner.Track => (await database.Tracks.AsNoTracking() + .Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active) + .Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName)) + .SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes), + CoverOwner.Album => (await database.Albums.AsNoTracking() + .Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null) + .Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName)) + .SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes), + _ => (await database.PlayLists.AsNoTracking() + .Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null) + .Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName)) + .SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes) + }; + + var entity = request.Owner.ToString(); + if (source == null) + return AppErrors.NotFound($"{entity}Cover", request.Id); + + var resizedName = request.Size switch + { + PictureSize.Small => source.Small, + PictureSize.Large => source.Large, + _ => source.Medium + }; + + // The resized files only exist once processing has finished; until then serve the original. + var (name, key) = !string.IsNullOrEmpty(resizedName) + ? (resizedName, routes.BuildPicturePath(request.Size, resizedName)) + : !string.IsNullOrEmpty(source.Original) + ? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original)) + : (null, null); + + if (name == null || key == null) + return AppErrors.NotFound($"{entity}Cover", request.Id); + + return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name)); + } +} diff --git a/backend/Core/Musify.Application/Pictures/PictureSourceChange.cs b/backend/Core/Musify.Application/Pictures/PictureSourceChange.cs new file mode 100644 index 0000000..ea8070b --- /dev/null +++ b/backend/Core/Musify.Application/Pictures/PictureSourceChange.cs @@ -0,0 +1,35 @@ +using ErrorOr; +using Musify.Application.Configuration; +using Musify.Application.Services; +using Musify.Application.Shared; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Pictures; + +/// What an album or playlist needs to switch to a freshly uploaded picture. +public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes); + +public static class PictureSourceChange +{ + public static async Task> PrepareAsync( + UploadIntentValidator uploadIntentValidator, + Guid intentId, + long userId, + UploadIntentPurpose purpose, + IPictureOwnerConfiguration owner, + CancellationToken cancellationToken) + { + var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken); + if (validation.IsError) + return validation.Errors; + + var intent = validation.Value; + + return new PreparedPicture( + intent, + EntityPictures.Pending(intent.ObjectName), + owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName), + owner.PicturesSizes.ToImageSizes(owner.Routes)); + } +} diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs b/backend/Core/Musify.Application/Pictures/Responses/PictureUploadResponse.cs similarity index 63% rename from backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs rename to backend/Core/Musify.Application/Pictures/Responses/PictureUploadResponse.cs index bc39921..60cafb6 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs +++ b/backend/Core/Musify.Application/Pictures/Responses/PictureUploadResponse.cs @@ -1,6 +1,6 @@ -namespace Musify.Application.Albums.Responses; +namespace Musify.Application.Pictures.Responses; -public record AlbumPictureUploadResponse( +public record PictureUploadResponse( Guid IntentId, string Bucket, string Key, diff --git a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index 76b8e2b..2b37a88 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -4,10 +4,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; -using Musify.Application.Shared; using Musify.Application.Events; +using Musify.Application.Pictures; using Musify.Application.PlayLists.Responses; using Musify.Application.Services; +using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; @@ -26,106 +27,52 @@ public class CreatePlayListCommandHandler( IDatabase database, UploadIntentValidator uploadIntentValidator, ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - PlayListConfiguration playListConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) + PlayListConfiguration playListConfiguration) : ICommandHandler> { - private sealed record ResolvedPlayListPictures(EntityPictures? 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) + return AppErrors.NotFound("User", request.UserId); + + PreparedPicture? picture = null; + if (request.PictureIntentId is { } intentId) { - logger.LogWarning("User {UserId} not found", request.UserId); - return Error.NotFound(description: $"User {request.UserId} not found"); + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; + + picture = prepared.Value; } - var picturesResult = await ResolvePicturesAsync(request, cancellationToken); - if (picturesResult.IsError) - return picturesResult.Errors; - - var resolvedPictures = picturesResult.Value; - + var name = request.Name.Trim(); var playList = new PlayList { OwnerUserId = request.UserId, - Name = request.Name.Trim(), - NormalizedName = TextNormalizer.Normalize(request.Name), + Name = name, + NormalizedName = TextNormalizer.Normalize(name), Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), - Pictures = resolvedPictures.Pictures, + Pictures = picture?.Pictures, Visibility = request.Visibility }; await database.PlayLists.AddAsync(playList, cancellationToken); - if (resolvedPictures.Intent != null) + if (picture != 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); - } - - 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, UploadIntentPurpose.PlayListPicture, cancellationToken); - - if (validation.IsError) - return validation.Errors; - - return new ResolvedPlayListPictures(EntityPictures.Pending(validation.Value.ObjectName), validation.Value); - } - - private async Task> PublishCreatePlayListEventAsync( - Guid playListId, - UploadIntent pictureIntent, - string finalPictureKey, - CancellationToken cancellationToken) - { - try - { - var sizes = playListConfiguration.PicturesSizes.ToImageSizes(playListConfiguration.Routes); await eventBus.PublishAsync( new CreatePlayListResourcesEvent( - playListId, - pictureIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - sizes.Small, - sizes.Medium, - sizes.Large), + playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), cancellationToken); - return Result.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); + + logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId); + + return PlayListApplicationResponse.FromEntity(playList); } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs deleted file mode 100644 index fab4d48..0000000 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs +++ /dev/null @@ -1,57 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using MimeMapping; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Application.Shared; -using Musify.Domain.ValueObjects; - -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 async ValueTask> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken) - { - var owned = await database.PlayLists.AsNoTracking() - .Where(e => e.Id == request.PlayListId && e.LifeCycleStatus == LifeCycleStatus.Active) - .Select(e => new { OwnerUserId = e.OwnerUserId, e.Pictures }) - .SingleOrDefaultAsync(cancellationToken); - - if (owned?.Pictures == null) - return AppErrors.NotFound("PlayListCover", request.PlayListId); - - var pictures = owned.Pictures; - var routes = playListConfiguration.Routes; - var size = PictureSizeParser.Parse(request.Size); - var name = size switch - { - PictureSize.Small => pictures.SmallName, - PictureSize.Large => pictures.LargeName, - _ => pictures.MediumName - }; - var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name); - - // The resized files only exist once processing has finished; until then serve the original. - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) - { - name = pictures.OriginalName; - key = string.IsNullOrEmpty(name) ? null : routes.BuildOriginalPicturePath(owned.OwnerUserId, name); - } - - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) - return AppErrors.NotFound("PlayListCover", request.PlayListId); - - var contentType = MimeUtility.GetMimeMapping(name); - return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType); - } -} diff --git a/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs index 6603940..44d9e68 100644 --- a/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs +++ b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs @@ -1,12 +1,8 @@ using ErrorOr; 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.Pictures.Responses; using Musify.Application.Services; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; namespace Musify.Application.PlayLists; @@ -16,89 +12,13 @@ public record RequestPlayListPictureUploadCommand( string FileType, string ContentType, long? ExpectedSizeBytes = null) - : ICommand>; + : ICommand>; -public class RequestPlayListPictureUploadCommandHandler( - IDatabase database, - UploadIntentValidator uploadIntentValidator, - IStorageService storageService, - ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - PlayListConfiguration playListConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : ICommandHandler> +public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration) + : ICommandHandler> { - public async ValueTask> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) - { - 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.BuildTempPath( - uploadIntentConfiguration.TempRootPrefix, 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} failed upload intent quota check", request.UserId); - return quotaCheck.Errors; - } - - 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"); - } - } + public async ValueTask> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) => + await issuer.IssuePictureAsync( + request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, + request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); } diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs deleted file mode 100644 index 6937944..0000000 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -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 53a2dd1..1d29ec1 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -5,10 +5,10 @@ using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Events; +using Musify.Application.Pictures; using Musify.Application.PlayLists.Responses; using Musify.Application.Services; using Musify.Application.Shared; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; namespace Musify.Application.PlayLists; @@ -27,108 +27,54 @@ public class UpdatePlayListCommandHandler( IDatabase database, UploadIntentValidator uploadIntentValidator, ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - PlayListConfiguration playListConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) + PlayListConfiguration playListConfiguration) : ICommandHandler> { public async ValueTask> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) { - 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(); - } + var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; - if (playListEntity.OwnerUserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return AppErrors.Forbidden("PlayList", request.PlayListId); - } + var playList = found.Value; if (!string.IsNullOrWhiteSpace(request.NewName)) { - playListEntity.Name = request.NewName.Trim(); - playListEntity.NormalizedName = TextNormalizer.Normalize(request.NewName); + playList.Name = request.NewName.Trim(); + playList.NormalizedName = TextNormalizer.Normalize(request.NewName); } + // null means "unchanged"; a blank description clears it. if (request.NewDescription != null) + playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim(); + + if (request.NewVisibility is { } visibility) + playList.Visibility = visibility; + + if (request.NewPictureIntentId is { } intentId) { - playListEntity.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim(); - } + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; - if (request.NewVisibility.HasValue) - { - playListEntity.Visibility = request.NewVisibility.Value; - } + var picture = prepared.Value; + playList.Pictures = picture.Pictures; - if (request.NewPictureIntentId.HasValue) - { - var validation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, - request.NewPictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, cancellationToken); - if (validation.IsError) - return validation.Errors; - - var pictureIntent = validation.Value; - playListEntity.Pictures = EntityPictures.Pending(pictureIntent.ObjectName); - var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); - - 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 - { - var sizes = playListConfiguration.PicturesSizes.ToImageSizes(playListConfiguration.Routes); await eventBus.PublishAsync( new UpdatePlayListPictureSourceEvent( - playListId, - pictureIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - sizes.Small, - sizes.Medium, - sizes.Large), + playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), cancellationToken); - return Result.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}"); } + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Updated playlist {PlayListId}", playList.Id); + + return await database.PlayLists + .AsNoTracking() + .Where(p => p.Id == playList.Id) + .SelectResponse() + .SingleAsync(cancellationToken); } } diff --git a/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs b/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs new file mode 100644 index 0000000..18660f5 --- /dev/null +++ b/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs @@ -0,0 +1,133 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Pictures.Responses; +using Musify.Application.Shared; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Services; + +public sealed record UploadRequest( + UploadIntentPurpose Purpose, + PictureRoutes Routes, + string FileType, + string ContentType, + long? ExpectedSizeBytes); + +public sealed record IssuedUpload( + Guid IntentId, + string Key, + string ObjectName, + string ContentType, + string UploadUrl); + +/// Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first. +public sealed class UploadIntentIssuer( + IDatabase database, + IStorageService storageService, + UploadIntentValidator uploadIntentValidator, + ApplicationStorageConfiguration storageConfiguration, + UploadIntentConfiguration uploadIntentConfiguration, + ILogger logger) +{ + public async Task>> IssueAsync( + long userId, + IReadOnlyList requests, + CancellationToken cancellationToken) + { + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(user => user.Id == userId, cancellationToken); + if (!userExists) + return AppErrors.NotFound("User", userId); + + var planned = requests + .Select(request => + { + var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; + var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName); + var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose); + return (Request: request, ObjectName: objectName, Key: key, Size: size); + }) + .ToList(); + + await using var transaction = await database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, cancellationToken); + + var quota = await uploadIntentValidator.CheckQuotaAsync( + userId, planned.Sum(item => item.Size), planned.Count, cancellationToken); + if (quota.IsError) + return quota.Errors; + + var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds); + var expiresAt = DateTime.UtcNow + expiresIn; + var issued = new List(planned.Count); + + foreach (var item in planned) + { + var uploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken); + + var intent = new UploadIntent + { + UserId = userId, + Bucket = storageConfiguration.Bucket, + Key = item.Key, + ObjectName = item.ObjectName, + ContentType = item.Request.ContentType, + ExpectedSizeBytes = item.Size, + Purpose = item.Request.Purpose, + ExpiresAt = expiresAt, + }; + + await database.UploadIntents.AddAsync(intent, cancellationToken); + issued.Add(new IssuedUpload(intent.Id, item.Key, item.ObjectName, item.Request.ContentType, uploadUrl)); + } + + await database.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + logger.LogInformation("Issued {Count} upload intents for user {UserId} ({Purposes})", + issued.Count, userId, string.Join('/', planned.Select(item => item.Request.Purpose))); + + return issued; + } + + public async Task> IssueOneAsync(long userId, UploadRequest request, CancellationToken cancellationToken) + { + var issued = await IssueAsync(userId, [request], cancellationToken); + return issued.IsError ? issued.Errors : issued.Value[0]; + } + + public async Task> IssuePictureAsync( + long userId, + UploadIntentPurpose purpose, + IPictureOwnerConfiguration owner, + string fileType, + string contentType, + long? expectedSizeBytes, + CancellationToken cancellationToken) + { + var issued = await IssueOneAsync( + userId, new UploadRequest(purpose, owner.Routes, fileType, contentType, expectedSizeBytes), cancellationToken); + if (issued.IsError) + return issued.Errors; + + return new PictureUploadResponse( + issued.Value.IntentId, + storageConfiguration.Bucket, + issued.Value.Key, + issued.Value.ObjectName, + issued.Value.ContentType, + uploadIntentConfiguration.UploadUrlExpiresInSeconds, + issued.Value.UploadUrl); + } + + private long DefaultSize(UploadIntentPurpose purpose) => + purpose == UploadIntentPurpose.TrackAudio + ? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes + : uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; +} diff --git a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs index 039e57e..4888ea7 100644 --- a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs @@ -2,40 +2,49 @@ using ErrorOr; using Microsoft.EntityFrameworkCore; using Musify.Application.Configuration; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Shared; namespace Musify.Application.Services; public sealed class UploadIntentValidator( IDatabase database, - IStorageService storageService) + IStorageService storageService, + UploadIntentConfiguration config) { public async Task> CheckQuotaAsync( - UploadIntentConfiguration config, long userId, long requiredBytes, int requiredIntentCount, CancellationToken cancellationToken) { - var activeIntents = await database.UploadIntents - .AsNoTracking() - .Where(i => i.UserId == userId && i.Status == UploadIntentStatus.Issued) - .ToListAsync(cancellationToken); + var now = DateTime.UtcNow; + var pictureDefault = config.DefaultExpectedPictureSizeBytes; + var audioDefault = config.DefaultExpectedAudioSizeBytes; - if (activeIntents.Count + requiredIntentCount > config.MaxActiveUploadIntentsPerUser) + var active = await database.UploadIntents + .AsNoTracking() + .Where(intent => intent.UserId == userId && intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt > now) + .GroupBy(_ => 1) + .Select(group => new + { + Count = group.Count(), + Bytes = group.Sum(intent => intent.ExpectedSizeBytes + ?? (intent.Purpose == UploadIntentPurpose.TrackAudio ? audioDefault : pictureDefault)) + }) + .SingleOrDefaultAsync(cancellationToken); + + if ((active?.Count ?? 0) + requiredIntentCount > config.MaxActiveUploadIntentsPerUser) return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire."); - var activeBytes = activeIntents.Sum(i => i.ExpectedSizeBytes ?? config.DefaultExpectedPictureSizeBytes); - if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser) + if ((active?.Bytes ?? 0) + requiredBytes > config.MaxActiveUploadBytesPerUser) return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire."); return Result.Success; } public async Task> ValidateAndLoadAsync( - UploadIntentConfiguration config, Guid intentId, long userId, UploadIntentPurpose purpose, diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index cd4ed2a..ec28219 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -27,9 +27,7 @@ public class CreateTrackCommandHandler( IEventBus eventBus, UploadIntentValidator uploadIntentValidator, ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - TrackConfiguration trackConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) + TrackConfiguration trackConfiguration) : ICommandHandler> { public async ValueTask> Handle(CreateTrackCommand request, CancellationToken cancellationToken) @@ -63,13 +61,11 @@ public class CreateTrackCommandHandler( return Error.Validation(description: "Picture and audio must use different upload intents."); var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken); if (pictureValidation.IsError) return pictureValidation.Errors; var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken); if (audioValidation.IsError) return audioValidation.Errors; @@ -115,40 +111,22 @@ public class CreateTrackCommandHandler( TrackId = trackEntity.Id }, cancellationToken); - try - { - var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes); - await eventBus.PublishAsync( - new CreateTrackResourcesEvent( - trackEntity.Id, - pictureIntent.Id, - audioIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - audioIntent.Key, - finalAudioKey, - audioProcessedFolderKey, - sizes.Small, - sizes.Medium, - sizes.Large), - 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}"); - } + var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes); + await eventBus.PublishAsync( + new CreateTrackResourcesEvent( + trackEntity.Id, + pictureIntent.Id, + audioIntent.Id, + pictureIntent.Bucket, + pictureIntent.Key, + finalPictureKey, + audioIntent.Key, + finalAudioKey, + audioProcessedFolderKey, + sizes), + cancellationToken); - 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}"); - } + await database.SaveChangesAsync(cancellationToken); return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0); } diff --git a/backend/Core/Musify.Application/Tracks/GetTrackCover.cs b/backend/Core/Musify.Application/Tracks/GetTrackCover.cs deleted file mode 100644 index 51a963c..0000000 --- a/backend/Core/Musify.Application/Tracks/GetTrackCover.cs +++ /dev/null @@ -1,50 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Application.Shared; -using Musify.Domain.ValueObjects; -using MimeMapping; - -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 async ValueTask> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken) - { - var track = await database.Tracks.AsNoTracking() - .Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active) - .Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName }) - .SingleOrDefaultAsync(cancellationToken); - - if (track == null) - return AppErrors.NotFound("TrackCover", request.TrackId); - - var routes = trackConfiguration.Routes; - var size = PictureSizeParser.Parse(request.Size); - var name = size switch - { - PictureSize.Small => track.SmallName, - PictureSize.Large => track.LargeName, - _ => track.MediumName - }; - var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name); - - if (string.IsNullOrEmpty(name) || key == null) - return AppErrors.NotFound("TrackCover", request.TrackId); - - var contentType = MimeUtility.GetMimeMapping(name); - - return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType); - } -} diff --git a/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs index 143dce0..5c51124 100644 --- a/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs +++ b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs @@ -1,12 +1,8 @@ using ErrorOr; 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; namespace Musify.Application.Tracks; @@ -22,10 +18,7 @@ public record RequestTrackUploadUrlsCommand( : ICommand>; public class RequestTrackUploadUrlsCommandHandler( - IDatabase database, - UploadIntentValidator uploadIntentValidator, - IStorageService storageService, - ILogger logger, + UploadIntentIssuer issuer, ApplicationStorageConfiguration storageConfiguration, TrackConfiguration trackConfiguration, UploadIntentConfiguration uploadIntentConfiguration) @@ -33,110 +26,32 @@ public class RequestTrackUploadUrlsCommandHandler( { public async ValueTask> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken) { - 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 routes = trackConfiguration.Routes; + var issued = await issuer.IssueAsync( + request.UserId, + [ + new UploadRequest(UploadIntentPurpose.TrackPicture, routes, request.PictureFileType, request.PictureContentType, request.ExpectedPictureSizeBytes), + new UploadRequest(UploadIntentPurpose.TrackAudio, routes, request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes) + ], + cancellationToken); + if (issued.IsError) + return issued.Errors; - var effectivePictureSize = request.ExpectedPictureSizeBytes - ?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; - var effectiveAudioSize = request.ExpectedAudioSizeBytes - ?? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes; + var picture = issued.Value[0]; + var audio = issued.Value[1]; - var pictureObjectName = $"{Guid.NewGuid()}.{request.PictureFileType.TrimStart('.').ToLowerInvariant()}"; - var audioObjectName = $"{Guid.NewGuid()}.{request.AudioFileType.TrimStart('.').ToLowerInvariant()}"; - - var tempPictureKey = trackConfiguration.Routes.BuildTempPath( - uploadIntentConfiguration.TempRootPrefix, request.UserId, pictureObjectName); - var tempAudioKey = trackConfiguration.Routes.BuildTempPath( - uploadIntentConfiguration.TempRootPrefix, 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} 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) - { - logger.LogError(exception, "Failed to generate track upload URLs for user {UserId}", request.UserId); - return Error.Failure(description: "Failed to generate upload URLs"); - } + return new TrackUploadUrlsResponse( + picture.IntentId, + audio.IntentId, + storageConfiguration.Bucket, + picture.Key, + picture.ObjectName, + picture.ContentType, + picture.UploadUrl, + audio.Key, + audio.ObjectName, + audio.ContentType, + audio.UploadUrl, + uploadIntentConfiguration.UploadUrlExpiresInSeconds); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs index c5c15c1..ae54c09 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs @@ -21,9 +21,7 @@ internal class PublishAlbumPictureProcessingEventActivity( arguments.AlbumId, arguments.Bucket, arguments.FinalPictureKey, - arguments.Small, - arguments.Medium, - arguments.Large), + arguments.Sizes), executeContext.CancellationToken); logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs index 530774d..c1b104c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs @@ -21,9 +21,7 @@ internal class PublishPlayListPictureProcessingEventActivity( arguments.PlayListId, arguments.Bucket, arguments.FinalPictureKey, - arguments.Small, - arguments.Medium, - arguments.Large), + arguments.Sizes), executeContext.CancellationToken); logger.LogInformation("Published picture processing event for playlist {PlayListId}", arguments.PlayListId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs index 8e2f03a..8cf6b1f 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs @@ -21,9 +21,7 @@ internal class PublishTrackProcessingEventsActivity( arguments.TrackId, arguments.Bucket, arguments.FinalPictureKey, - arguments.Small, - arguments.Medium, - arguments.Large), + arguments.Sizes), executeContext.CancellationToken); await publishEndpoint.Publish( diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs index 6600b1f..10cfa7a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs @@ -6,6 +6,4 @@ public record PublishAlbumPictureProcessingEventArguments( Guid AlbumId, string Bucket, string FinalPictureKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs index 31000ae..b4831c0 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs @@ -6,6 +6,4 @@ public record PublishPlayListPictureProcessingEventArguments( Guid PlayListId, string Bucket, string FinalPictureKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs index f451b98..6bc151b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs @@ -8,6 +8,4 @@ public record PublishTrackProcessingEventsArguments( string FinalPictureKey, string FinalAudioKey, string AudioDestinationFolderKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs index 866dbca..5beae29 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs @@ -19,9 +19,7 @@ public class AlbumPictureSourceRoutingSlipBuilder message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, - message.Small, - message.Medium, - message.Large, + message.Sizes, correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumCreation); @@ -32,9 +30,7 @@ public class AlbumPictureSourceRoutingSlipBuilder message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, - message.Small, - message.Medium, - message.Large, + message.Sizes, correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumPicture); @@ -44,9 +40,7 @@ public class AlbumPictureSourceRoutingSlipBuilder string bucket, string pictureSourceKey, string pictureDestinationKey, - ImageSize small, - ImageSize medium, - ImageSize large, + ImageSizes sizes, Guid? correlationId, string processKind) { @@ -78,9 +72,7 @@ public class AlbumPictureSourceRoutingSlipBuilder albumId, bucket, pictureDestinationKey, - small, - medium, - large)); + sizes)); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, albumId); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs index 426f544..f50a844 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs @@ -53,9 +53,7 @@ public class CreateTrackRoutingSlipBuilder message.PictureDestinationKey, message.AudioDestinationKey, message.AudioProcessedFolderKey, - message.Small, - message.Medium, - message.Large)); + message.Sizes)); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackCreation); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs index b827afb..ba6fa48 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs @@ -17,9 +17,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu var routingSlipBuilder = BuildPictureWorkflow( message.Bucket, message.SourceKey, - message.Small, - message.Medium, - message.Large, + message.Sizes, correlationId); routingSlipBuilder.AddActivity( @@ -46,9 +44,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu var routingSlipBuilder = BuildPictureWorkflow( message.Bucket, message.SourceKey, - message.Small, - message.Medium, - message.Large, + message.Sizes, correlationId); routingSlipBuilder.AddActivity( @@ -75,9 +71,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu var routingSlipBuilder = BuildPictureWorkflow( message.Bucket, message.SourceKey, - message.Small, - message.Medium, - message.Large, + message.Sizes, correlationId); routingSlipBuilder.AddActivity( @@ -102,9 +96,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu private RoutingSlipBuilder BuildPictureWorkflow( string bucket, string sourceKey, - ImageSize small, - ImageSize medium, - ImageSize large, + ImageSizes sizes, Guid? correlationId) { var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); @@ -135,7 +127,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu ActivityNames.UploadSmall, bucket, RoutingSlipVariableNames.Picture.SmallResizedFilePath, - small); + sizes.Small); AddResizeActivity( routingSlipBuilder, @@ -143,7 +135,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu ActivityNames.UploadMedium, bucket, RoutingSlipVariableNames.Picture.MediumResizedFilePath, - medium); + sizes.Medium); AddResizeActivity( routingSlipBuilder, @@ -151,7 +143,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu ActivityNames.UploadLarge, bucket, RoutingSlipVariableNames.Picture.LargeResizedFilePath, - large); + sizes.Large); return routingSlipBuilder; } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs index 6e79a3b..6744749 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs @@ -19,9 +19,7 @@ public class PlayListPictureSourceRoutingSlipBuilder message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, - message.Small, - message.Medium, - message.Large, + message.Sizes, correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListCreation); @@ -32,9 +30,7 @@ public class PlayListPictureSourceRoutingSlipBuilder message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, - message.Small, - message.Medium, - message.Large, + message.Sizes, correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListPicture); @@ -44,9 +40,7 @@ public class PlayListPictureSourceRoutingSlipBuilder string bucket, string pictureSourceKey, string pictureDestinationKey, - ImageSize small, - ImageSize medium, - ImageSize large, + ImageSizes sizes, Guid? correlationId, string processKind) { @@ -78,9 +72,7 @@ public class PlayListPictureSourceRoutingSlipBuilder playListId, bucket, pictureDestinationKey, - small, - medium, - large)); + sizes)); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, playListId); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind); diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index d75a5b5..e4ad0e9 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -6,6 +6,8 @@ using Musify.Api.Filters; using Musify.Application.Albums; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; +using Musify.Application.Pictures; +using Musify.Application.Pictures.Responses; using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; @@ -65,7 +67,7 @@ public static class AlbumEndpoints .WithSummary("Request A Pre-Signed URL To Upload An Album Picture.") .AddEndpointFilter>() .RequireAuthorization() - .Produces() + .Produces() .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); @@ -179,7 +181,7 @@ public static class AlbumEndpoints return result.ToHttpResult(); } - private static async Task GetAlbumCover( + private static Task GetAlbumCover( IMediator mediator, IStorageService storageService, HttpResponse response, @@ -187,17 +189,7 @@ public static class AlbumEndpoints CancellationToken cancellationToken, string size = "medium") { - var result = await mediator.Send(new GetAlbumCoverQuery(id, size), cancellationToken); - if (result.IsError) - return Results.NotFound(); - - var location = result.Value; - var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); - if (stream == null) - return Results.NotFound(); - - response.Headers.CacheControl = "public, max-age=31536000, immutable"; - return Results.Stream(stream, location.ContentType); + return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Album, id, size, cancellationToken); } private static async Task RequestAlbumPictureUpload( diff --git a/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs b/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs new file mode 100644 index 0000000..7bf3570 --- /dev/null +++ b/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs @@ -0,0 +1,31 @@ +using Mediator; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Pictures; + +namespace Musify.Api.Endpoints; + +internal static class CoverEndpoint +{ + public static async Task Stream( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + CoverOwner owner, + Guid id, + string size, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetCoverQuery(owner, id, PictureSizeParser.Parse(size)), cancellationToken); + if (result.IsError) + return Results.NotFound(); + + var location = result.Value; + var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); + if (stream == null) + return Results.NotFound(); + + response.Headers.CacheControl = "public, max-age=31536000, immutable"; + return Results.Stream(stream, location.ContentType); + } +} diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index d3daa30..70ca6ce 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -4,6 +4,8 @@ using Musify.Api.DataTransferObjects.PlayLists; using Musify.Api.Extensions; using Musify.Api.Filters; using Musify.Application.Contracts; +using Musify.Application.Pictures; +using Musify.Application.Pictures.Responses; using Musify.Application.PlayLists; using Musify.Application.PlayLists.Responses; using Musify.Api.Models; @@ -80,7 +82,7 @@ public static class PlayListEndpoints .WithSummary("Request A Pre-Signed URL To Upload A PlayList Picture.") .AddEndpointFilter>() .RequireAuthorization() - .Produces() + .Produces() .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); @@ -140,7 +142,7 @@ public static class PlayListEndpoints return result.ToHttpResult(); } - private static async Task GetPlayListCover( + private static Task GetPlayListCover( IMediator mediator, IStorageService storageService, HttpResponse response, @@ -148,17 +150,7 @@ public static class PlayListEndpoints CancellationToken cancellationToken, string size = "medium") { - var result = await mediator.Send(new GetPlayListCoverQuery(id, size), cancellationToken); - if (result.IsError) - return Results.NotFound(); - - var location = result.Value; - var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); - if (stream == null) - return Results.NotFound(); - - response.Headers.CacheControl = "public, max-age=31536000, immutable"; - return Results.Stream(stream, location.ContentType); + return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.PlayList, id, size, cancellationToken); } private static async Task CreatePlayList( diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index a718e6d..8b6e765 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -4,6 +4,7 @@ using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.Extensions; using Musify.Api.Filters; using Musify.Application.Contracts; +using Musify.Application.Pictures; using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks; @@ -128,7 +129,7 @@ public static class TrackEndpoints return result.ToHttpResult(); } - private static async Task GetTrackCover( + private static Task GetTrackCover( IMediator mediator, IStorageService storageService, HttpResponse response, @@ -136,17 +137,7 @@ public static class TrackEndpoints CancellationToken cancellationToken, string size = "medium") { - var result = await mediator.Send(new GetTrackCoverQuery(id, size), cancellationToken); - if (result.IsError) - return Results.NotFound(); - - var location = result.Value; - var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); - if (stream == null) - return Results.NotFound(); - - response.Headers.CacheControl = "public, max-age=31536000, immutable"; - return Results.Stream(stream, location.ContentType); + return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Track, id, size, cancellationToken); } private static async Task GetTrackStream( diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index 68745e8..793eca0 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using Musify.Api.DataTransferObjects.Albums; using Musify.Api.Tests.TestSupport; using Musify.Application.Albums.Responses; +using Musify.Application.Pictures.Responses; using Musify.Application.Contracts; using NSubstitute; using Xunit; @@ -29,7 +30,7 @@ public sealed class AlbumEndpointsTests(ApiTestFixture fixture) TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); return body!.IntentId; } diff --git a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs index 42b56d7..5e5972c 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs @@ -16,11 +16,9 @@ public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase private CreateAlbumCommandHandler CreateHandler() => new( eventBus, Database, - new UploadIntentValidator(Database, storageService), + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.Album(), - TestConfigurations.UploadIntent()); + TestConfigurations.Album()); [Fact] public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent() diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs deleted file mode 100644 index 00c82be..0000000 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using ErrorOr; -using Musify.Application.Albums; -using Musify.Application.Tests.TestSupport; -using Xunit; - -namespace Musify.Application.Tests.Albums; - -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) - { - 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); - - 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); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } - - [Fact] - public async Task Handle_PicturesStillProcessing_FallsBackToTheOriginal() - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id, pictures: Musify.Domain.ValueObjects.EntityPictures.Pending("original.png")); - await SeedAsync(owner, album); - - var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, "small"), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Contains($"{owner.Id}", result.Value.Key); - Assert.EndsWith("original.png", result.Value.Key); - Assert.Equal("image/png", result.Value.ContentType); - } -} diff --git a/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs index 1a17d19..05d4a2e 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs @@ -20,14 +20,17 @@ public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBa .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://storage.musify.test/presigned-upload"); + var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); + return new RequestAlbumPictureUploadCommandHandler( - Database, - new UploadIntentValidator(Database, storageService), - storageService, - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.Album(), - uploadIntentConfig ?? TestConfigurations.UploadIntent()); + new UploadIntentIssuer( + Database, + storageService, + new UploadIntentValidator(Database, storageService, config), + TestConfigurations.Storage(), + config, + NoOpLogger()), + TestConfigurations.Album()); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs index b1c6695..41916a3 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs @@ -16,11 +16,9 @@ public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase private UpdateAlbumCommandHandler CreateHandler() => new( eventBus, Database, - new UploadIntentValidator(Database, storageService), + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.Album(), - TestConfigurations.UploadIntent()); + TestConfigurations.Album()); [Fact] public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear() diff --git a/backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs new file mode 100644 index 0000000..63a3c8f --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs @@ -0,0 +1,104 @@ +using ErrorOr; +using Musify.Application.Configuration; +using Musify.Application.Pictures; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Application.Tests.Pictures; + +public sealed class GetCoverQueryHandlerTests : HandlerTestBase +{ + private GetCoverQueryHandler CreateHandler() => new( + Database, + TestConfigurations.Storage("covers-bucket"), + TestConfigurations.Track(), + TestConfigurations.Album(), + TestConfigurations.PlayList()); + + [Theory] + [InlineData(PictureSize.Small, "small.webp")] + [InlineData(PictureSize.Medium, "medium.webp")] + [InlineData(PictureSize.Large, "large.webp")] + public async Task Handle_ProcessedTrack_ReturnsRequestedSize(PictureSize size, string expectedFileName) + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Track, 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); + } + + [Theory] + [InlineData(PictureSize.Small, "small.webp")] + [InlineData(PictureSize.Medium, "medium.webp")] + [InlineData(PictureSize.Large, "large.webp")] + public async Task Handle_ProcessedAlbum_ReturnsRequestedSize(PictureSize size, string expectedFileName) + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, album); + + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, size), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.EndsWith(expectedFileName, result.Value.Key); + } + + [Fact] + public async Task Handle_ProcessedPlayList_ReturnsRequestedSize() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.PlayList, playList.Id, PictureSize.Large), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.EndsWith("large.webp", result.Value.Key); + } + + [Theory] + [InlineData(CoverOwner.Track)] + [InlineData(CoverOwner.Album)] + [InlineData(CoverOwner.PlayList)] + public async Task Handle_EntityMissing_ReturnsNotFound(CoverOwner owner) + { + var result = await CreateHandler().Handle(new GetCoverQuery(owner, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_AlbumPicturesStillProcessing_FallsBackToTheOriginal() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id, pictures: EntityPictures.Pending("original.png")); + await SeedAsync(owner, album); + + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, PictureSize.Small), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Contains($"{owner.Id}", result.Value.Key); + Assert.EndsWith("original.png", result.Value.Key); + Assert.Equal("image/png", result.Value.ContentType); + } + + [Fact] + public async Task Handle_RemovingAlbum_ReturnsNotFound() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + album.LifeCycleStatus = LifeCycleStatus.Removing; + await SeedAsync(owner, album); + + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs index a724a5a..53ffc79 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs @@ -16,11 +16,9 @@ public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase private CreatePlayListCommandHandler CreateHandler() => new( eventBus, Database, - new UploadIntentValidator(Database, storageService), + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.PlayList(), - TestConfigurations.UploadIntent()); + TestConfigurations.PlayList()); [Fact] public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures() diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs deleted file mode 100644 index 6a7e02f..0000000 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -using ErrorOr; -using Musify.Application.PlayLists; -using Musify.Application.Tests.TestSupport; -using Xunit; - -namespace Musify.Application.Tests.PlayLists; - -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) - { - 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); - - 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); - - 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); - - var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, "small"), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } -} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs index 78eab3f..d08a8ae 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs @@ -20,14 +20,17 @@ public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTes .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://storage.musify.test/presigned-upload"); + var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); + return new RequestPlayListPictureUploadCommandHandler( - Database, - new UploadIntentValidator(Database, storageService), - storageService, - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.PlayList(), - uploadIntentConfig ?? TestConfigurations.UploadIntent()); + new UploadIntentIssuer( + Database, + storageService, + new UploadIntentValidator(Database, storageService, config), + TestConfigurations.Storage(), + config, + NoOpLogger()), + TestConfigurations.PlayList()); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs index 848f58c..c151868 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs @@ -16,11 +16,9 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase private UpdatePlayListCommandHandler CreateHandler() => new( eventBus, Database, - new UploadIntentValidator(Database, storageService), + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.PlayList(), - TestConfigurations.UploadIntent()); + TestConfigurations.PlayList()); [Fact] public async Task Handle_Owner_RenamesPlayList() diff --git a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs index ec75365..95967e3 100644 --- a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs @@ -1,4 +1,5 @@ using ErrorOr; +using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Services; using Musify.Application.Tests.TestSupport; @@ -13,7 +14,8 @@ 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); + private UploadIntentValidator CreateValidator(UploadIntentConfiguration? config = null) => + new(Database, storageService, config ?? TestConfigurations.UploadIntent()); [Fact] public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent() @@ -25,7 +27,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp"); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.Equal(intent.Id, result.Value.Id); @@ -37,7 +39,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id, UploadIntentPurpose.TrackAudio); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type); } @@ -45,7 +47,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase [Fact] public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound() { - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -57,7 +59,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id); await SeedAsync(owner, stranger, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -68,7 +70,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Conflict, result.FirstError.Type); } @@ -79,7 +81,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1)); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type); } @@ -94,7 +96,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -112,7 +114,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + var result = await CreateValidator(config).ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type); } @@ -122,7 +124,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase { await SeedAsync(owner); - var result = await CreateValidator().CheckQuotaAsync(TestConfigurations.UploadIntent(), owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken); + var result = await CreateValidator().CheckQuotaAsync(owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken); Assert.False(result.IsError); } @@ -134,7 +136,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase config.MaxActiveUploadIntentsPerUser = 1; await SeedAsync(owner, TestEntities.UploadIntent(owner.Id)); - var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); + var result = await CreateValidator(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type); } @@ -149,7 +151,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired), TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed)); - var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); + var result = await CreateValidator(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); Assert.False(result.IsError); } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs index dda3226..26a32e6 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -24,11 +24,9 @@ public sealed class CreateTrackCommandHandlerTests : HandlerTestBase return new CreateTrackCommandHandler( Database, eventBus, - new UploadIntentValidator(Database, storageService), + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.Track(), - TestConfigurations.UploadIntent()); + TestConfigurations.Track()); } private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) => diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs deleted file mode 100644 index a4a8615..0000000 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using ErrorOr; -using Musify.Application.Tests.TestSupport; -using Musify.Application.Tracks; -using Xunit; - -namespace Musify.Application.Tests.Tracks; - -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) - { - 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); - - 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); - - 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); - - var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, "small"), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } -} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs index eab3b41..6eb2e52 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs @@ -21,14 +21,19 @@ public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://storage.musify.test/presigned-upload"); + var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); + return new RequestTrackUploadUrlsCommandHandler( - Database, - new UploadIntentValidator(Database, storageService), - storageService, - NoOpLogger(), + new UploadIntentIssuer( + Database, + storageService, + new UploadIntentValidator(Database, storageService, config), + TestConfigurations.Storage(), + config, + NoOpLogger()), TestConfigurations.Storage(), TestConfigurations.Track(), - uploadIntentConfig ?? TestConfigurations.UploadIntent()); + config); } [Fact] diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 6e889eb..1fb9389 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -752,17 +752,6 @@ export interface components { updatedAt: string; coverTrackIds: string[]; }; - AlbumPictureUploadResponse: { - /** Format: uuid */ - intentId: string; - bucket: string; - key: string; - pictureName: string; - contentType: string; - /** Format: int32 */ - expiresInSeconds: number | string; - uploadUrl: string; - }; AvailableGenreResponse: { genre: components["schemas"]["Genre"]; incompatibleWith: components["schemas"]["Genre"][]; @@ -889,6 +878,17 @@ export interface components { hasNextPage: boolean; hasPreviousPage: boolean; }; + PictureUploadResponse: { + /** Format: uuid */ + intentId: string; + bucket: string; + key: string; + pictureName: string; + contentType: string; + /** Format: int32 */ + expiresInSeconds: number | string; + uploadUrl: string; + }; PlaybackPublicConfigResponse: { allowAnonymousListening: boolean; /** Format: int32 */ @@ -914,17 +914,6 @@ export interface components { /** Format: double */ durationSeconds: number | string; }; - PlayListPictureUploadResponse: { - /** Format: uuid */ - intentId: string; - bucket: string; - key: string; - pictureName: string; - contentType: string; - /** Format: int32 */ - expiresInSeconds: number | string; - uploadUrl: string; - }; /** @enum {unknown} */ PlayListVisibility: "Private" | "Public"; /** @enum {unknown} */ @@ -1301,7 +1290,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlbumPictureUploadResponse"]; + "application/json": components["schemas"]["PictureUploadResponse"]; }; }; /** @description Bad Request */ @@ -2041,7 +2030,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PlayListPictureUploadResponse"]; + "application/json": components["schemas"]["PictureUploadResponse"]; }; }; /** @description Bad Request */ From 0fa033faffaaa020f7c6904caa2b291454b8a5e5 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 12:14:53 +0200 Subject: [PATCH 10/21] Separate responsibilities: start-listening command, API-side JSON converters, bulk track endpoints, mix kinds --- .../Albums/AddTrackToAlbum.cs | 58 - .../Albums/AddTracksToAlbum.cs | 77 ++ .../Responses/AlbumApplicationResponse.cs | 4 +- .../Musify.Application/Contracts/IDatabase.cs | 2 - .../Musify.Application/Genres/GetGenres.cs | 2 +- .../Mixes/GenerateMixesForUser.cs | 27 +- .../Musify.Application/Mixes/GetMixById.cs | 2 +- .../Mixes/GetMixesByUserId.cs | 2 +- .../Mixes/Responses/MixApplicationResponse.cs | 5 +- .../PlayLists/AddTrackToPlayList.cs | 61 - .../PlayLists/AddTracksToPlayList.cs | 73 ++ .../Responses/PlayListApplicationResponse.cs | 4 +- .../Services/TrackStreamIssuer.cs | 27 +- .../Musify.Application/Tracks/CreateTrack.cs | 20 - .../Tracks/RecordListeningProgress.cs | 3 - .../Responses/TrackApplicationResponse.cs | 4 +- .../{GetTrackStream.cs => StartListening.cs} | 30 +- .../Responses/UserApplicationResponse.cs | 4 +- .../Users/Responses/UserProfileResponse.cs | 4 +- .../Users/Responses/UserSummaryResponse.cs | 4 +- backend/Core/Musify.Domain/Entities/Mix.cs | 7 +- backend/Core/Musify.Domain/Entities/Track.cs | 2 - backend/Core/Musify.Domain/Entities/User.cs | 2 - .../Musify.Domain/Entities/UserHasTrack.cs | 19 - .../Musify.Domain/ValueObjects/MixKind.cs | 7 + .../Persistence/Database.cs | 2 - ...0260924101123_DropUserHasTrack.Designer.cs | 1090 +++++++++++++++++ .../20260924101123_DropUserHasTrack.cs | 57 + .../20260924101237_MixKind.Designer.cs | 1084 ++++++++++++++++ .../Migrations/20260924101237_MixKind.cs | 55 + .../Migrations/DatabaseModelSnapshot.cs | 56 +- .../DataTransferObjects/AddTracksRequest.cs | 3 + .../PlayLists/AddPlayListTrackRequest.cs | 3 - .../Musify.Api/Endpoints/AlbumEndpoints.cs | 17 +- .../Musify.Api/Endpoints/PlayListEndpoints.cs | 27 +- .../Musify.Api/Endpoints/TrackEndpoints.cs | 22 +- .../Hosts/Musify.Api/Models/GenreParameter.cs | 19 + .../Musify.Api/OpenApi/OpenApiOptionsSetup.cs | 13 +- backend/Hosts/Musify.Api/Program.cs | 7 +- .../Serialization/LongAsStringConverter.cs | 2 +- .../Validators/AddTracksRequestValidator.cs | 20 + ...RecordListeningProgressRequestValidator.cs | 14 + .../Endpoints/GenreEndpointsTests.cs | 1 - .../Validators/TrackRequestValidatorTests.cs | 48 + ...=> AddTracksToAlbumCommandHandlerTests.cs} | 23 +- .../Genres/GetGenresQueryHandlerTests.cs | 33 +- ...GenerateMixesForUserCommandHandlerTests.cs | 20 +- .../Mixes/GetMixByIdQueryHandlerTests.cs | 5 +- .../GetMixesByUserIdQueryHandlerTests.cs | 9 +- ...AddTracksToPlayListCommandHandlerTests.cs} | 43 +- .../Services/TrackStreamIssuerTests.cs | 69 +- .../TestSupport/TestDatabase.cs | 2 - .../TestSupport/TestEntities.cs | 6 +- .../Tracks/CreateTrackCommandHandlerTests.cs | 28 - .../GetTracksByUserIdQueryHandlerTests.cs | 5 +- .../Tracks/GetTracksQueryHandlerTests.cs | 12 +- ...ordListeningProgressCommandHandlerTests.cs | 13 - ...s => StartListeningCommandHandlerTests.cs} | 17 +- web-player/src/lib/api/schema.d.ts | 211 ++-- web-player/src/lib/data/mixes.ts | 15 + web-player/src/lib/server/playlistActions.ts | 29 +- .../routes/(app)/albums/[id]/+page.server.ts | 5 +- .../src/routes/(app)/mixes/[id]/+page.svelte | 13 +- web-player/src/routes/+page.svelte | 6 +- 64 files changed, 2903 insertions(+), 651 deletions(-) delete mode 100644 backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs create mode 100644 backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs delete mode 100644 backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs create mode 100644 backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs rename backend/Core/Musify.Application/Tracks/{GetTrackStream.cs => StartListening.cs} (60%) delete mode 100644 backend/Core/Musify.Domain/Entities/UserHasTrack.cs create mode 100644 backend/Core/Musify.Domain/ValueObjects/MixKind.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.Designer.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.Designer.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.cs create mode 100644 backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs delete mode 100644 backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs create mode 100644 backend/Hosts/Musify.Api/Models/GenreParameter.cs rename backend/{Core/Musify.Application => Hosts/Musify.Api}/Serialization/LongAsStringConverter.cs (96%) create mode 100644 backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs create mode 100644 backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs create mode 100644 backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs rename backend/Tests/Musify.Application.Tests/Albums/{AddTrackToAlbumCommandHandlerTests.cs => AddTracksToAlbumCommandHandlerTests.cs} (69%) rename backend/Tests/Musify.Application.Tests/PlayLists/{AddTrackToPlayListCommandHandlerTests.cs => AddTracksToPlayListCommandHandlerTests.cs} (58%) rename backend/Tests/Musify.Application.Tests/Tracks/{GetTrackStreamQueryHandlerTests.cs => StartListeningCommandHandlerTests.cs} (75%) diff --git a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs deleted file mode 100644 index ee2902e..0000000 --- a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs +++ /dev/null @@ -1,58 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Application.Shared; -using Musify.Domain.Entities; - -namespace Musify.Application.Albums; - -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) - { - var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); - if (album.IsError) - return album.Errors; - - var track = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken); - if (track.IsError) - return track.Errors; - - var alreadyAdded = await database.AlbumHasTracks - .AsNoTracking() - .AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken); - if (alreadyAdded) - return AlreadyInAlbum; - - // Album track numbers are user-visible, so they start at 1. - var nextTrackNumber = (await database.AlbumHasTracks - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) - .MaxAsync(albumTrack => (int?)albumTrack.TrackNumber, cancellationToken) ?? 0) + 1; - - await database.AlbumHasTracks.AddAsync(new AlbumHasTrack - { - AlbumId = request.AlbumId, - TrackId = request.TrackId, - TrackNumber = nextTrackNumber - }, cancellationToken); - - var saved = await database.TrySaveChangesAsync(AlreadyInAlbum, cancellationToken); - if (saved.IsError) - return saved; - - logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}", - request.TrackId, request.AlbumId, nextTrackNumber); - - return Result.Success; - } - - private static Error AlreadyInAlbum => AppErrors.Conflict("Album.TrackAlreadyAdded", "Track is already in the album."); -} diff --git a/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs b/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs new file mode 100644 index 0000000..39a0679 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs @@ -0,0 +1,77 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Albums; + +/// Appends your own tracks to an album in the given order, skipping the ones it already has. +public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList TrackIds) + : ICommand>; + +public class AddTracksToAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> +{ + public async ValueTask> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken) + { + var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (album.IsError) + return album.Errors; + + var requestedIds = request.TrackIds.Distinct().ToList(); + + var tracks = await database.Tracks + .AsNoTracking() + .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => new { track.Id, track.OwnerUserId }) + .ToListAsync(cancellationToken); + + var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id)); + if (missing != Guid.Empty) + return AppErrors.NotFound("Track", missing); + + var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId); + if (foreign != null) + return AppErrors.Forbidden("Track", foreign.Id); + + var alreadyInAlbum = await database.AlbumHasTracks + .AsNoTracking() + .Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId)) + .Select(link => link.TrackId) + .ToListAsync(cancellationToken); + + var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList(); + if (toAdd.Count == 0) + return Result.Success; + + // Album track numbers are user-visible, so they start at 1. + var nextTrackNumber = (await database.AlbumHasTracks + .Where(link => link.AlbumId == request.AlbumId) + .MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1; + + foreach (var trackId in toAdd) + { + await database.AlbumHasTracks.AddAsync(new AlbumHasTrack + { + AlbumId = request.AlbumId, + TrackId = trackId, + TrackNumber = nextTrackNumber++ + }, cancellationToken); + } + + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken); + if (saved.IsError) + return saved; + + logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId); + + return Result.Success; + } +} diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs index eae6f11..5a97f89 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; -using Musify.Application.Serialization; using Musify.Domain.Entities; namespace Musify.Application.Albums.Responses; @@ -9,7 +7,7 @@ public record AlbumApplicationResponse( string Title, string? Description, int? ReleaseYear, - [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, + long OwnerUserId, int TrackCount, string? SmallImageKeyName, string? MediumImageKeyName, diff --git a/backend/Core/Musify.Application/Contracts/IDatabase.cs b/backend/Core/Musify.Application/Contracts/IDatabase.cs index 9bed6b1..5ec518a 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabase.cs @@ -16,8 +16,6 @@ public interface IDatabase public DbSet Tracks { get; } - public DbSet UserHasTracks { get; } - public DbSet PlayListHasTracks { get; } public DbSet Mixes { get; } diff --git a/backend/Core/Musify.Application/Genres/GetGenres.cs b/backend/Core/Musify.Application/Genres/GetGenres.cs index 4d54270..396db60 100644 --- a/backend/Core/Musify.Application/Genres/GetGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetGenres.cs @@ -15,7 +15,7 @@ public class GetGenresQueryHandler(IDatabase database) { var rows = await database.TrackTags .AsNoTracking() - .Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)) + .Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active) .GroupBy(tag => tag.Tag) .Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() }) .ToListAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs index 587aa3a..38e16ff 100644 --- a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs +++ b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -18,16 +18,16 @@ public class GenerateMixesForUserCommandHandler( ILogger logger) : ICommandHandler> { - private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList TrackIds); + private sealed record MixDraft(MixKind Kind, IReadOnlyList TrackIds); public async ValueTask> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) { var userId = request.UserId; - var libraryTrackIds = await database.UserHasTracks + var libraryTrackIds = await database.Tracks .AsNoTracking() - .Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) - .Select(userTrack => userTrack.TrackId) + .Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => track.Id) .ToListAsync(cancellationToken); if (libraryTrackIds.Count == 0) @@ -51,19 +51,13 @@ public class GenerateMixesForUserCommandHandler( 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)); + drafts.Add(new MixDraft(MixKind.Discovery, 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)); + drafts.Add(new MixDraft(MixKind.Daily, daily)); } if (drafts.Count == 0) @@ -108,12 +102,6 @@ public class GenerateMixesForUserCommandHandler( 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); - - database.MixItems.RemoveRange(previousItems); database.Mixes.RemoveRange(previousMixes); } @@ -123,8 +111,7 @@ public class GenerateMixesForUserCommandHandler( var mix = new Mix { UserId = userId, - Title = draft.Title, - Subtitle = draft.Subtitle, + Kind = draft.Kind, Position = position }; diff --git a/backend/Core/Musify.Application/Mixes/GetMixById.cs b/backend/Core/Musify.Application/Mixes/GetMixById.cs index a831c12..e9e44f1 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixById.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixById.cs @@ -26,6 +26,6 @@ public class GetMixByIdQueryHandler(IDatabase database) 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.Kind, items.Count, items); } } diff --git a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs index bcb68b3..303c41c 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs @@ -42,7 +42,7 @@ public class GetMixesByUserIdQueryHandler(IDatabase database) .Select(item => coverResponses[item.TrackId]) .ToList(); - responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, covers)); + responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers)); } return responses; diff --git a/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs index bb83d13..801988e 100644 --- a/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs +++ b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs @@ -1,9 +1,10 @@ +using Musify.Domain.ValueObjects; + namespace Musify.Application.Mixes.Responses; public record MixApplicationResponse( Guid Id, - string Title, - string? Subtitle, + MixKind Kind, int ItemCount, IReadOnlyList Items) { diff --git a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs deleted file mode 100644 index 7e9184a..0000000 --- a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs +++ /dev/null @@ -1,61 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Application.Shared; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; - -namespace Musify.Application.PlayLists; - -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) - { - var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); - if (playList.IsError) - return playList.Errors; - - var trackExists = await database.Tracks - .AsNoTracking() - .AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); - if (!trackExists) - return AppErrors.NotFound("Track", request.TrackId); - - var alreadyAdded = await database.PlayListHasTracks - .AsNoTracking() - .AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken); - if (alreadyAdded) - return AlreadyInPlayList; - - // Playlist positions are internal indexes, so they start at 0. - var nextPosition = (await database.PlayListHasTracks - .Where(plt => plt.PlayListId == request.PlayListId) - .MaxAsync(plt => (int?)plt.Position, cancellationToken) ?? -1) + 1; - - await database.PlayListHasTracks.AddAsync(new PlayListHasTrack - { - PlayListId = request.PlayListId, - TrackId = request.TrackId, - Position = nextPosition - }, cancellationToken); - - var saved = await database.TrySaveChangesAsync(AlreadyInPlayList, cancellationToken); - if (saved.IsError) - return saved; - - logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}", - request.TrackId, request.PlayListId, nextPosition); - - return Result.Success; - } - - private static Error AlreadyInPlayList => AppErrors.Conflict("PlayList.TrackAlreadyAdded", "Track is already in the playlist."); -} diff --git a/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs new file mode 100644 index 0000000..12d5bb4 --- /dev/null +++ b/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs @@ -0,0 +1,73 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.PlayLists; + +/// Appends tracks to a playlist in the given order, skipping the ones it already has. +public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList TrackIds) + : ICommand>; + +public class AddTracksToPlayListCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> +{ + public async ValueTask> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken) + { + var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (playList.IsError) + return playList.Errors; + + var requestedIds = request.TrackIds.Distinct().ToList(); + + var existingIds = await database.Tracks + .AsNoTracking() + .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => track.Id) + .ToListAsync(cancellationToken); + + var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id)); + if (missing != Guid.Empty) + return AppErrors.NotFound("Track", missing); + + var alreadyInPlayList = await database.PlayListHasTracks + .AsNoTracking() + .Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId)) + .Select(link => link.TrackId) + .ToListAsync(cancellationToken); + + var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList(); + if (toAdd.Count == 0) + return Result.Success; + + // Playlist positions are internal indexes, so they start at 0. + var nextPosition = (await database.PlayListHasTracks + .Where(link => link.PlayListId == request.PlayListId) + .MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1; + + foreach (var trackId in toAdd) + { + await database.PlayListHasTracks.AddAsync(new PlayListHasTrack + { + PlayListId = request.PlayListId, + TrackId = trackId, + Position = nextPosition++ + }, cancellationToken); + } + + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken); + if (saved.IsError) + return saved; + + logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId); + + return Result.Success; + } +} diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs index 8b9ddb4..48ea745 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs @@ -1,8 +1,6 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using System.Text.Json.Serialization; -using Musify.Application.Serialization; namespace Musify.Application.PlayLists.Responses; @@ -17,7 +15,7 @@ public record PlayListApplicationResponse( DateTime CreatedAt, DateTime UpdatedAt, IReadOnlyList CoverTrackIds, - [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, + long OwnerUserId, int TrackCount, double DurationSeconds) { diff --git a/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs b/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs index 490cfb0..6786cf7 100644 --- a/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs +++ b/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs @@ -1,28 +1,20 @@ using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; namespace Musify.Application.Services; +/// Builds the stream URL and signed ticket for a processed track. It never touches the database. public sealed class TrackStreamIssuer( - IDatabase database, IStreamTicketService ticketService, TrackConfiguration trackConfiguration, StreamGatewayConfiguration streamGatewayConfiguration, PlaybackConfiguration playbackConfiguration) { - public async Task IssueAsync( - Guid trackId, - string audioFolderName, - long? userId, - CancellationToken cancellationToken) + public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId) { var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName); - var keyPrefix = $"{folderPath}/"; - - var maxBytes = AnonymousFragmentBytes(userId); - var ticket = ticketService.IssueTicket(userId, keyPrefix, maxBytes); + var ticket = ticketService.IssueTicket(userId, $"{folderPath}/", AnonymousFragmentBytes(userId)); var manifestUrl = string.Join('/', streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'), @@ -30,19 +22,6 @@ public sealed class TrackStreamIssuer( folderPath, streamGatewayConfiguration.AudioFileName); - Guid? listenId = null; - if (userId != null) - { - var newListeningHistory = new ListeningHistory - { - UserId = userId.Value, - TrackId = trackId, - }; - await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - listenId = newListeningHistory.Id; - } - return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId); } diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index ec28219..6c42b75 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -41,21 +41,6 @@ public class CreateTrackCommandHandler( } 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."); - } if (request.PictureIntentId == request.AudioIntentId) return Error.Validation(description: "Picture and audio must use different upload intents."); @@ -105,11 +90,6 @@ public class CreateTrackCommandHandler( await database.Tracks.AddAsync(trackEntity, cancellationToken); - await database.UserHasTracks.AddAsync(new UserHasTrack - { - UserId = request.UserId, - TrackId = trackEntity.Id - }, cancellationToken); var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes); await eventBus.PublishAsync( diff --git a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs index 0cc95cc..9ced6ff 100644 --- a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs +++ b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs @@ -17,9 +17,6 @@ public class RecordListeningProgressCommandHandler(IDatabase database) { 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."); - var listen = await database.ListeningHistories .Include(l => l.Track) .SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken); diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs index 12a76de..da88141 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; -using Musify.Application.Serialization; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; @@ -14,7 +12,7 @@ public record TrackApplicationResponse( int ListensCount, DateTime CreatedAt, DateTime UpdatedAt, - [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, + long OwnerUserId, IReadOnlyCollection Tags, bool IsExplicit) { diff --git a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs b/backend/Core/Musify.Application/Tracks/StartListening.cs similarity index 60% rename from backend/Core/Musify.Application/Tracks/GetTrackStream.cs rename to backend/Core/Musify.Application/Tracks/StartListening.cs index 6678931..d580698 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs +++ b/backend/Core/Musify.Application/Tracks/StartListening.cs @@ -5,24 +5,25 @@ using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Services; -using Musify.Application.Tracks.Responses; -using Musify.Domain.ValueObjects; using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; +/// Starts a listen: records it for signed-in users and issues the stream ticket. +public record StartListeningCommand(Guid TrackId, long? UserId) + : ICommand>; -public record GetTrackStreamQuery(Guid TrackId, long? UserId) - : IQuery>; - -public class GetTrackStreamQueryHandler( +public class StartListeningCommandHandler( IDatabase database, TrackStreamIssuer streamIssuer, PlaybackConfiguration playbackConfiguration, - ILogger logger) - : IQueryHandler> + ILogger logger) + : ICommandHandler> { - public async ValueTask> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(StartListeningCommand 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."); @@ -38,10 +39,17 @@ public class GetTrackStreamQueryHandler( if (!track.Audio.IsProcessed) return AppErrors.Conflict("Track.AudioNotReady", "Track audio is not available for streaming yet."); - var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken); + Guid? listenId = null; + if (request.UserId is { } userId) + { + var listen = new ListeningHistory { UserId = userId, TrackId = track.Id }; + await database.ListeningHistories.AddAsync(listen, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + listenId = listen.Id; + } logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString() ?? "(anonymous)"); - return response; + return streamIssuer.Issue(track.Audio.FolderName, request.UserId, listenId); } } diff --git a/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs index d0d8840..2ab2b7b 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs @@ -1,11 +1,9 @@ -using System.Text.Json.Serialization; -using Musify.Application.Serialization; using Musify.Domain.Entities; namespace Musify.Application.Users.Responses; public record UserApplicationResponse( - [property: JsonConverter(typeof(LongAsStringConverter))] long Id, + long Id, string Name, string? FirstName, string? SecondName, diff --git a/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs index b1d9520..1c3bf41 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs @@ -1,10 +1,8 @@ -using System.Text.Json.Serialization; -using Musify.Application.Serialization; namespace Musify.Application.Users.Responses; public record UserProfileResponse( - [property: JsonConverter(typeof(LongAsStringConverter))] long Id, + long Id, string Name, string? FirstName, string? SecondName, diff --git a/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs index 23eedf3..34651c6 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs @@ -1,10 +1,8 @@ -using System.Text.Json.Serialization; -using Musify.Application.Serialization; namespace Musify.Application.Users.Responses; public record UserSummaryResponse( - [property: JsonConverter(typeof(LongAsStringConverter))] long Id, + long Id, string Name, string? FirstName, string? SecondName, diff --git a/backend/Core/Musify.Domain/Entities/Mix.cs b/backend/Core/Musify.Domain/Entities/Mix.cs index 9487931..52df127 100644 --- a/backend/Core/Musify.Domain/Entities/Mix.cs +++ b/backend/Core/Musify.Domain/Entities/Mix.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Musify.Domain.ValueObjects; using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; @@ -13,11 +14,7 @@ public class Mix : IAuditable public required long UserId { get; set; } - [MaxLength(100)] - public required string Title { get; set; } - - [MaxLength(256)] - public string? Subtitle { get; set; } + public MixKind Kind { get; set; } public int Position { get; set; } diff --git a/backend/Core/Musify.Domain/Entities/Track.cs b/backend/Core/Musify.Domain/Entities/Track.cs index 8474fed..04da190 100644 --- a/backend/Core/Musify.Domain/Entities/Track.cs +++ b/backend/Core/Musify.Domain/Entities/Track.cs @@ -36,8 +36,6 @@ public class Track : IAuditable, IHasLifeCycle, IOwnedEntity public TrackAudio Audio { get; set; } = new(); - public ICollection UserTracks { get; set; } = new List(); - public ICollection PlayListTracks { get; set; } = new List(); public ICollection AlbumTracks { get; set; } = new List(); diff --git a/backend/Core/Musify.Domain/Entities/User.cs b/backend/Core/Musify.Domain/Entities/User.cs index e12912c..7616a1e 100644 --- a/backend/Core/Musify.Domain/Entities/User.cs +++ b/backend/Core/Musify.Domain/Entities/User.cs @@ -30,8 +30,6 @@ public class User : IAuditable public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - public ICollection UserTracks { get; set; } = new List(); - public ICollection PlayLists { get; set; } = new List(); public ICollection ListeningHistories { get; set; } = new List(); diff --git a/backend/Core/Musify.Domain/Entities/UserHasTrack.cs b/backend/Core/Musify.Domain/Entities/UserHasTrack.cs deleted file mode 100644 index d2998b9..0000000 --- a/backend/Core/Musify.Domain/Entities/UserHasTrack.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; - -namespace Musify.Domain.Entities; - -[Table("UserHasTrack")] -public class UserHasTrack -{ - public Guid Id { get; set; } = Guid.NewGuid(); - - public required long UserId { get; set; } - - public required Guid TrackId { get; set; } - - [ForeignKey(nameof(UserId))] - public User User { get; set; } = null!; - - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } = null!; -} diff --git a/backend/Core/Musify.Domain/ValueObjects/MixKind.cs b/backend/Core/Musify.Domain/ValueObjects/MixKind.cs new file mode 100644 index 0000000..2e3fb69 --- /dev/null +++ b/backend/Core/Musify.Domain/ValueObjects/MixKind.cs @@ -0,0 +1,7 @@ +namespace Musify.Domain.ValueObjects; + +public enum MixKind +{ + Discovery = 0, + Daily = 1 +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index 70e5e9f..0814c5c 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -181,8 +181,6 @@ public class Database(DatabaseConfiguration configuration) public DbSet AlbumHasTracks => Set(); - public DbSet UserHasTracks => Set(); - public DbSet PlayListHasTracks => Set(); public DbSet Mixes => Set(); diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.Designer.cs new file mode 100644 index 0000000..91dcf21 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.Designer.cs @@ -0,0 +1,1090 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260924101123_DropUserHasTrack")] + partial class DropUserHasTrack + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsCounted") + .HasColumnType("boolean"); + + b.Property("LastProgressAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayedSeconds") + .HasColumnType("double precision"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint") + .HasColumnName("UserId"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("PlayListId", "TrackId") + .IsUnique(); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId", "TrackId") + .IsUnique(); + + b.ToTable("TrackLikes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowedId") + .HasColumnType("bigint"); + + b.Property("FollowerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FollowedId"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique(); + + b.ToTable("UserFollows"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany("PlayLists") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.HasOne("Musify.Domain.Entities.User", "Followed") + .WithMany() + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.cs new file mode 100644 index 0000000..cd4fd04 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class DropUserHasTrack : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserHasTrack"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserHasTrack", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TrackId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserHasTrack", x => x.Id); + table.ForeignKey( + name: "FK_UserHasTrack_Tracks_TrackId", + column: x => x.TrackId, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserHasTrack_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserHasTrack_TrackId", + table: "UserHasTrack", + column: "TrackId"); + + migrationBuilder.CreateIndex( + name: "IX_UserHasTrack_UserId", + table: "UserHasTrack", + column: "UserId"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.Designer.cs new file mode 100644 index 0000000..94412dd --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.Designer.cs @@ -0,0 +1,1084 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260924101237_MixKind")] + partial class MixKind + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsCounted") + .HasColumnType("boolean"); + + b.Property("LastProgressAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayedSeconds") + .HasColumnType("double precision"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint") + .HasColumnName("UserId"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("PlayListId", "TrackId") + .IsUnique(); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId", "TrackId") + .IsUnique(); + + b.ToTable("TrackLikes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowedId") + .HasColumnType("bigint"); + + b.Property("FollowerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FollowedId"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique(); + + b.ToTable("UserFollows"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany("PlayLists") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.HasOne("Musify.Domain.Entities.User", "Followed") + .WithMany() + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.cs new file mode 100644 index 0000000..9427cc7 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class MixKind : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Kind", + table: "Mixes", + type: "integer", + nullable: false, + defaultValue: 0); + + // Mixes used to store their Spanish title; the daily mix is kind 1, everything else is discovery. + migrationBuilder.Sql("UPDATE \"Mixes\" SET \"Kind\" = 1 WHERE \"Title\" = 'Tu mezcla diaria';"); + + migrationBuilder.DropColumn( + name: "Subtitle", + table: "Mixes"); + + migrationBuilder.DropColumn( + name: "Title", + table: "Mixes"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Kind", + table: "Mixes"); + + migrationBuilder.AddColumn( + name: "Subtitle", + table: "Mixes", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "Title", + table: "Mixes", + type: "character varying(100)", + maxLength: 100, + nullable: false, + defaultValue: ""); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 1ad303c..84961db 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -304,17 +304,11 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("Position") + b.Property("Kind") .HasColumnType("integer"); - b.Property("Subtitle") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("Title") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + b.Property("Position") + .HasColumnType("integer"); b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); @@ -626,27 +620,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("UserFollows"); }); - modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("TrackId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("bigint"); - - b.HasKey("Id"); - - b.HasIndex("TrackId"); - - b.HasIndex("UserId"); - - b.ToTable("UserHasTrack"); - }); - modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => { b.Property("CorrelationId") @@ -1070,25 +1043,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("Follower"); }); - modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => - { - b.HasOne("Musify.Domain.Entities.Track", "Track") - .WithMany("UserTracks") - .HasForeignKey("TrackId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Musify.Domain.Entities.User", "User") - .WithMany("UserTracks") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Track"); - - b.Navigation("User"); - }); - modelBuilder.Entity("Musify.Domain.Entities.Album", b => { b.Navigation("AlbumTracks"); @@ -1113,8 +1067,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("PlayListTracks"); b.Navigation("Tags"); - - b.Navigation("UserTracks"); }); modelBuilder.Entity("Musify.Domain.Entities.User", b => @@ -1122,8 +1074,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("ListeningHistories"); b.Navigation("PlayLists"); - - b.Navigation("UserTracks"); }); #pragma warning restore 612, 618 } diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs new file mode 100644 index 0000000..6c0d0e5 --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs @@ -0,0 +1,3 @@ +namespace Musify.Api.DataTransferObjects; + +public record AddTracksRequest(IReadOnlyList TrackIds); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs deleted file mode 100644 index d000ed6..0000000 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Musify.Api.DataTransferObjects.PlayLists; - -public record AddPlayListTrackRequest(Guid TrackId); diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index e4ad0e9..481e0d4 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -1,5 +1,6 @@ using Mediator; using Musify.Api.Authentication; +using Musify.Api.DataTransferObjects; using Musify.Api.DataTransferObjects.Albums; using Musify.Api.Extensions; using Musify.Api.Filters; @@ -102,11 +103,13 @@ public static class AlbumEndpoints .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); - group.MapPost("/{albumId}/tracks/{trackId}", AddTrackToAlbum) - .WithName("AddTrackToAlbum") - .WithSummary("Add One Of Your Uploaded Tracks To An Album.") + group.MapPost("/{id}/tracks", AddAlbumTracks) + .WithName("AddAlbumTracks") + .WithSummary("Add Your Uploaded Tracks To An Album, Skipping The Ones It Already Has.") + .AddEndpointFilter>() .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) + .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) @@ -242,15 +245,15 @@ public static class AlbumEndpoints return result.ToHttpResult(); } - private static async Task AddTrackToAlbum( + private static async Task AddAlbumTracks( IMediator mediator, CurrentUser currentUser, - Guid albumId, - Guid trackId, + Guid id, + AddTracksRequest request, CancellationToken cancellationToken) { var result = await mediator.Send( - new AddTrackToAlbumCommand(currentUser.RequiredId, albumId, trackId), + new AddTracksToAlbumCommand(currentUser.RequiredId, id, request.TrackIds), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index 70ca6ce..c25e71d 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -1,5 +1,6 @@ using Mediator; using Musify.Api.Authentication; +using Musify.Api.DataTransferObjects; using Musify.Api.DataTransferObjects.PlayLists; using Musify.Api.Extensions; using Musify.Api.Filters; @@ -10,7 +11,6 @@ using Musify.Application.PlayLists; using Musify.Application.PlayLists.Responses; using Musify.Api.Models; using Musify.Application.Shared; -using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; namespace Musify.Api.Endpoints; @@ -87,11 +87,13 @@ public static class PlayListEndpoints .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); - group.MapPost("/{playlistId}/tracks", AddPlayListTrack) - .WithName("AddPlayListTrack") - .WithSummary("Add One Of Your Tracks To A PlayList.") + group.MapPost("/{id}/tracks", AddPlayListTracks) + .WithName("AddPlayListTracks") + .WithSummary("Add Tracks To A PlayList, Skipping The Ones It Already Has.") + .AddEndpointFilter>() .RequireAuthorization() - .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status204NoContent) + .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) @@ -203,21 +205,18 @@ public static class PlayListEndpoints return result.ToHttpResult(); } - private static async Task AddPlayListTrack( + private static async Task AddPlayListTracks( IMediator mediator, CurrentUser currentUser, - Guid playlistId, - AddPlayListTrackRequest request, + Guid id, + AddTracksRequest request, CancellationToken cancellationToken) { - var addResult = await mediator.Send( - new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, request.TrackId), + var result = await mediator.Send( + new AddTracksToPlayListCommand(currentUser.RequiredId, id, request.TrackIds), cancellationToken); - if (addResult.IsError) - return addResult.ToHttpResult(); - var trackResult = await mediator.Send(new GetTrackByIdQuery(request.TrackId), cancellationToken); - return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}"); + return result.ToHttpResult(); } private static async Task RemoveTrackFromPlayList( diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index 8b6e765..1a1fd88 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -9,7 +9,6 @@ using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; -using Musify.Domain.ValueObjects; namespace Musify.Api.Endpoints; @@ -47,6 +46,7 @@ public static class TrackEndpoints .Produces(StatusCodes.Status404NotFound); group.MapPut("/listens/{listenId}/progress", RecordListeningProgress) + .AddEndpointFilter>() .WithName("RecordListeningProgress") .WithSummary("Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint.") .RequireAuthorization() @@ -99,24 +99,10 @@ public static class TrackEndpoints IMediator mediator, CancellationToken cancellationToken, string? name, - string? genre, + GenreParameter? genre, [AsParameters] PageQuery page) { - Genre? parsedGenre = null; - if (!string.IsNullOrWhiteSpace(genre)) - { - if (!Enum.TryParse(genre, ignoreCase: true, out var value) || !Enum.IsDefined(value)) - { - return Results.ValidationProblem(new Dictionary - { - [nameof(genre)] = [$"'{genre}' is not a valid genre."] - }); - } - - parsedGenre = value; - } - - var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, parsedGenre), cancellationToken); + var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, genre?.Value), cancellationToken); return Results.Ok(result); } @@ -146,7 +132,7 @@ public static class TrackEndpoints Guid id, CancellationToken cancellationToken) { - var result = await mediator.Send(new GetTrackStreamQuery(id, currentUser.Id), cancellationToken); + var result = await mediator.Send(new StartListeningCommand(id, currentUser.Id), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Hosts/Musify.Api/Models/GenreParameter.cs b/backend/Hosts/Musify.Api/Models/GenreParameter.cs new file mode 100644 index 0000000..8539c14 --- /dev/null +++ b/backend/Hosts/Musify.Api/Models/GenreParameter.cs @@ -0,0 +1,19 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Api.Models; + +/// Query-string binding for a genre name (case-insensitive); unknown names are rejected with a 400. +public readonly record struct GenreParameter(Genre Value) +{ + public static bool TryParse(string? value, out GenreParameter result) + { + if (Enum.TryParse(value, ignoreCase: true, out var genre) && Enum.IsDefined(genre)) + { + result = new GenreParameter(genre); + return true; + } + + result = default; + return false; + } +} diff --git a/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs index d70dfbc..a7b1cbd 100644 --- a/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.Options; using Microsoft.OpenApi; using Musify.Api.Authentication; -using Musify.Application.Serialization; namespace Musify.Api.OpenApi; @@ -41,10 +40,16 @@ public sealed class OpenApiOptionsSetup(IOptions op options.AddSchemaTransformer((schema, context, _) => { - if (context.JsonPropertyInfo?.CustomConverter is LongAsStringConverter or NullableLongAsStringConverter) + // Every long the API returns is a user id, serialized as a string (JS loses precision above 2^53). + // Request DTOs only carry byte sizes, which may be sent as numbers or strings. + if (context.JsonPropertyInfo is { } property + && (property.PropertyType == typeof(long) || property.PropertyType == typeof(long?))) { - schema.Type = JsonSchemaType.String; - schema.Format = null; + var isRequest = property.DeclaringType?.Name.EndsWith("Request", StringComparison.Ordinal) == true; + schema.Type = isRequest ? JsonSchemaType.Integer | JsonSchemaType.String : JsonSchemaType.String; + if (property.PropertyType == typeof(long?)) + schema.Type |= JsonSchemaType.Null; + schema.Format = isRequest ? "int64" : null; } return Task.CompletedTask; diff --git a/backend/Hosts/Musify.Api/Program.cs b/backend/Hosts/Musify.Api/Program.cs index 28d26c7..c1f1257 100644 --- a/backend/Hosts/Musify.Api/Program.cs +++ b/backend/Hosts/Musify.Api/Program.cs @@ -1,13 +1,18 @@ using System.Text.Json.Serialization; using Musify.Api.Endpoints; using Musify.Api.Extensions; +using Musify.Api.Serialization; using Musify.Infrastructure.Observability; using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(); builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); +{ + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.SerializerOptions.Converters.Add(new LongAsStringConverter()); + options.SerializerOptions.Converters.Add(new NullableLongAsStringConverter()); +}); const string devCorsPolicy = "dev-cors"; diff --git a/backend/Core/Musify.Application/Serialization/LongAsStringConverter.cs b/backend/Hosts/Musify.Api/Serialization/LongAsStringConverter.cs similarity index 96% rename from backend/Core/Musify.Application/Serialization/LongAsStringConverter.cs rename to backend/Hosts/Musify.Api/Serialization/LongAsStringConverter.cs index a0d7642..587708c 100644 --- a/backend/Core/Musify.Application/Serialization/LongAsStringConverter.cs +++ b/backend/Hosts/Musify.Api/Serialization/LongAsStringConverter.cs @@ -2,7 +2,7 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; -namespace Musify.Application.Serialization; +namespace Musify.Api.Serialization; public sealed class LongAsStringConverter : JsonConverter { diff --git a/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs new file mode 100644 index 0000000..d3466e5 --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects; + +namespace Musify.Api.Validators; + +public sealed class AddTracksRequestValidator : AbstractValidator +{ + public const int MaxTracksPerRequest = 500; + + public AddTracksRequestValidator() + { + RuleFor(x => x.TrackIds) + .NotEmpty() + .Must(ids => ids.Count <= MaxTracksPerRequest) + .WithMessage($"At most {MaxTracksPerRequest} tracks can be added at once."); + + RuleForEach(x => x.TrackIds) + .NotEmpty(); + } +} diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs new file mode 100644 index 0000000..518be4b --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects.Tracks; + +namespace Musify.Api.Validators.Tracks; + +public sealed class RecordListeningProgressRequestValidator : AbstractValidator +{ + public RecordListeningProgressRequestValidator() + { + RuleFor(x => x.PlayedSeconds) + .Must(seconds => double.IsFinite(seconds) && seconds >= 0) + .WithMessage("'Played Seconds' must be a non-negative number."); + } +} diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs index b8790cb..28def3b 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs @@ -40,7 +40,6 @@ public sealed class GenreEndpointsTests(ApiTestFixture fixture) 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; } diff --git a/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs new file mode 100644 index 0000000..9643e18 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs @@ -0,0 +1,48 @@ +using Musify.Api.DataTransferObjects.Tracks; +using Musify.Api.Validators.Tracks; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Api.Tests.Validators; + +public sealed class TrackRequestValidatorTests +{ + [Fact] + public void CreateTrack_ValidRequest_Passes() + { + var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop, Genre.Rock]); + + Assert.True(new CreateTrackRequestValidator().Validate(request).IsValid); + } + + [Fact] + public void CreateTrack_NoTags_Fails() + { + var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), []); + + Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid); + } + + [Fact] + public void CreateTrack_IncompatibleTags_Fails() + { + var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Classical, Genre.Metal]); + + Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid); + } + + [Theory] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void RecordListeningProgress_InvalidSeconds_Fails(double seconds) + { + Assert.False(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(seconds)).IsValid); + } + + [Fact] + public void RecordListeningProgress_ZeroSeconds_Passes() + { + Assert.True(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(0)).IsValid); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs similarity index 69% rename from backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs rename to backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs index 784c48e..1619700 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs @@ -7,9 +7,9 @@ using Xunit; namespace Musify.Application.Tests.Albums; -public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase +public sealed class AddTracksToAlbumCommandHandlerTests : HandlerTestBase { - private AddTrackToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); + private AddTracksToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); [Fact] public async Task Handle_FirstTrack_AddsItAtNumberOne() @@ -19,7 +19,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase 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 AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); Assert.False(result.IsError); var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); @@ -33,7 +33,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase 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 AddTracksToAlbumCommand(owner.Id, Guid.NewGuid(), [track.Id]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -47,7 +47,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase 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 AddTracksToAlbumCommand(stranger.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } @@ -59,7 +59,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase 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 AddTracksToAlbumCommand(owner.Id, album.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -73,13 +73,13 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase 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 AddTracksToAlbumCommand(owner.Id, album.Id, [foreignTrack.Id]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] - public async Task Handle_AlreadyInAlbum_ReturnsConflict() + public async Task Handle_AlreadyInAlbum_IsSkipped() { var owner = TestEntities.User(); var album = TestEntities.Album(owner.Id); @@ -87,9 +87,10 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase 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 AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + Assert.False(result.IsError); + Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); } [Fact] @@ -102,7 +103,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase 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 AddTracksToAlbumCommand(owner.Id, album.Id, [secondTrack.Id]), TestContext.Current.CancellationToken); Assert.False(result.IsError); var newLink = await Database.AlbumHasTracks.SingleAsync(l => l.TrackId == secondTrack.Id, TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs index c2e4393..cb76ee5 100644 --- a/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs @@ -1,6 +1,5 @@ using Musify.Application.Genres; using Musify.Application.Tests.TestSupport; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Xunit; @@ -26,10 +25,7 @@ public sealed class GetGenresQueryHandlerTests : HandlerTestBase 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 }); + owner, rockA, rockB, jazz); var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); @@ -38,31 +34,4 @@ public sealed class GetGenresQueryHandlerTests : HandlerTestBase 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 }); - - var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); - - 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); - - var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); - - Assert.Empty(result); - } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs index ad45bd3..e7eb3c6 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Mixes; using Musify.Application.Tests.TestSupport; -using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; using Xunit; namespace Musify.Application.Tests.Mixes; @@ -30,7 +30,6 @@ public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase 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); @@ -38,7 +37,7 @@ public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase 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.Equal(MixKind.Daily, mixes[0].Kind); } [Fact] @@ -48,25 +47,23 @@ public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase var listenedTrack = TestEntities.Track(user, "Listened Track"); var unheardTrack = TestEntities.Track(user, "Unheard Track"); - var staleMix = TestEntities.Mix(user.Id, "Stale Mix"); + var staleMix = TestEntities.Mix(user.Id, MixKind.Daily); 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)); 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.DoesNotContain(mixes, mix => mix.Id == staleMix.Id); + Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery); + Assert.Contains(mixes, mix => mix.Kind == MixKind.Daily); Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken)); - var discoveryMix = mixes.Single(mix => mix.Title == "Descubrimiento"); + var discoveryMix = mixes.Single(mix => mix.Kind == MixKind.Discovery); var discoveryItems = await Database.MixItems .Where(item => item.MixId == discoveryMix.Id) .ToListAsync(TestContext.Current.CancellationToken); @@ -80,12 +77,11 @@ public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase 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); var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.Contains(mixes, mix => mix.Title == "Descubrimiento"); + Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery); } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs index e7a68b8..25274c3 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs @@ -1,6 +1,7 @@ using ErrorOr; using Musify.Application.Mixes; using Musify.Application.Tests.TestSupport; +using Musify.Domain.ValueObjects; using Xunit; namespace Musify.Application.Tests.Mixes; @@ -15,7 +16,7 @@ public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase var owner = TestEntities.User(); var firstTrack = TestEntities.Track(owner, "First"); var secondTrack = TestEntities.Track(owner, "Second"); - var mix = TestEntities.Mix(owner.Id, "Discovery"); + var mix = TestEntities.Mix(owner.Id, MixKind.Daily); var second = TestEntities.MixItem(mix.Id, secondTrack.Id, position: 1); var first = TestEntities.MixItem(mix.Id, firstTrack.Id, position: 0); await SeedAsync(owner, firstTrack, secondTrack, mix, second, first); @@ -23,7 +24,7 @@ public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase 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(MixKind.Daily, result.Value.Kind); Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title)); } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs index 9a20c90..f46344f 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs @@ -1,5 +1,6 @@ using Musify.Application.Mixes; using Musify.Application.Tests.TestSupport; +using Musify.Domain.ValueObjects; using Xunit; namespace Musify.Application.Tests.Mixes; @@ -13,13 +14,13 @@ public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase { var owner = TestEntities.User(1, "owner"); var other = TestEntities.User(2, "other"); - var second = TestEntities.Mix(owner.Id, "Second", position: 1); - var first = TestEntities.Mix(owner.Id, "First", position: 0); - var theirs = TestEntities.Mix(other.Id, "Theirs"); + var second = TestEntities.Mix(owner.Id, MixKind.Daily, position: 1); + var first = TestEntities.Mix(owner.Id, MixKind.Discovery, position: 0); + var theirs = TestEntities.Mix(other.Id); await SeedAsync(owner, other, second, first, theirs); var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - Assert.Equal(["First", "Second"], result.Select(mix => mix.Title)); + Assert.Equal([MixKind.Discovery, MixKind.Daily], result.Select(mix => mix.Kind)); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs similarity index 58% rename from backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs rename to backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs index fd3626b..f37c54e 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs @@ -7,9 +7,9 @@ using Xunit; namespace Musify.Application.Tests.PlayLists; -public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase +public sealed class AddTracksToPlayListCommandHandlerTests : HandlerTestBase { - private AddTrackToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); + private AddTracksToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); [Fact] public async Task Handle_FirstTrack_AddsItAtPositionZero() @@ -19,7 +19,7 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase 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 AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken); Assert.False(result.IsError); var link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); @@ -37,7 +37,7 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase 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 AddTracksToPlayListCommand(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); @@ -47,7 +47,7 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase [Fact] public async Task Handle_PlayListMissing_ReturnsNotFound() { - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(1, Guid.NewGuid(), [Guid.NewGuid()]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -60,7 +60,7 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase 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 AddTracksToPlayListCommand(stranger.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } @@ -72,22 +72,23 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase 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 AddTracksToPlayListCommand(owner.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } [Fact] - public async Task Handle_TrackAlreadyInPlayList_ReturnsConflict() + public async Task Handle_TrackAlreadyInPlayList_IsSkipped() { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id); var track = TestEntities.Track(owner); await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + Assert.False(result.IsError); + Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); } [Fact] @@ -103,4 +104,26 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase Assert.Equal("Test.Duplicate", result.FirstError.Code); } + + [Fact] + public async Task Handle_SeveralTracks_AppendsThemInOrderSkippingExistingOnes() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var existing = TestEntities.Track(owner, "Existing"); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + await SeedAsync( + owner, playList, existing, first, second, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = existing.Id, Position = 0 }); + + var result = await CreateHandler().Handle( + new AddTracksToPlayListCommand(owner.Id, playList.Id, [first.Id, existing.Id, second.Id, first.Id]), + TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var links = await Database.PlayListHasTracks.OrderBy(link => link.Position).ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal([existing.Id, first.Id, second.Id], links.Select(link => link.TrackId)); + Assert.Equal([0, 1, 2], links.Select(link => link.Position)); + } } diff --git a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs index 5fbecf3..21b4d47 100644 --- a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs @@ -1,4 +1,3 @@ -using Microsoft.EntityFrameworkCore; using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Services; @@ -8,90 +7,56 @@ using Xunit; namespace Musify.Application.Tests.Services; -public sealed class TrackStreamIssuerTests : HandlerTestBase +public sealed class TrackStreamIssuerTests { private readonly IStreamTicketService ticketService = Substitute.For(); + public TrackStreamIssuerTests() => + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("signed-token", 120)); + private TrackStreamIssuer CreateIssuer(PlaybackConfiguration? playback = null) => new( - Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway("https://stream.musify.test/"), playback ?? TestConfigurations.Playback()); [Fact] - public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory() + public void Issue_BuildsManifestUrlAndCarriesTheListenId() { - 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); + var listenId = Guid.NewGuid(); - var response = await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 7, TestContext.Current.CancellationToken); + var response = CreateIssuer().Issue("folder-42", userId: 7, listenId); 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 history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Equal(7, history.UserId); - Assert.Equal(track.Id, history.TrackId); + Assert.Equal(listenId, response.ListenId); + Assert.Equal("https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", response.ManifestUrl); } [Fact] - public async Task IssueAsync_PassesTheProcessedAudioKeyPrefixToTheTicketService() + public void Issue_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); - - await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken); + CreateIssuer().Issue("folder-42", userId: 1, listenId: null); ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); } [Fact] - public async Task IssueAsync_AnonymousUser_SkipsListeningHistoryAndNeverThrows() + public void Issue_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); - - var response = await CreateIssuer(TestConfigurations.Playback(allowAnonymousListening: true)) - .IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken); - - Assert.Equal("token", response.Ticket); - Assert.Empty(await Database.ListeningHistories.ToListAsync(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); - var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); - await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken); + + CreateIssuer(playback).Issue("folder-42", userId: null, listenId: null); ticketService.Received(1).IssueTicket(null, "Tracks/ProcessedAudios/folder-42/", 30L * playback.EstimatedAudioBytesPerSecond); } [Fact] - public async Task IssueAsync_AuthenticatedUser_IgnoresTheAnonymousFragmentCap() + public void Issue_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); + + CreateIssuer(playback).Issue("folder-42", userId: 1, listenId: null); ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs index 37bab0e..081ced1 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -143,8 +143,6 @@ public sealed class TestDatabase : DbContext, IDatabase public DbSet Tracks => Set(); - public DbSet UserHasTracks => Set(); - public DbSet PlayListHasTracks => Set(); public DbSet Mixes => Set(); diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs index b058d4d..3aeaca5 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -154,14 +154,12 @@ public static class TestEntities public static Mix Mix( long userId, - string title = "Test Mix", - string? subtitle = null, + MixKind kind = MixKind.Discovery, int position = 0) => new() { UserId = userId, - Title = title, - Subtitle = subtitle, + Kind = kind, Position = position }; diff --git a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs index 26a32e6..ecb346d 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -89,34 +89,6 @@ public sealed class CreateTrackCommandHandlerTests : HandlerTestBase 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); - - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, []); - - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - - 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); - - 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); - - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } - [Fact] public async Task Handle_UserMissing_ReturnsNotFound() { diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs index 2db7b57..4217572 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs @@ -1,6 +1,5 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; -using Musify.Domain.Entities; using Xunit; namespace Musify.Application.Tests.Tracks; @@ -17,9 +16,7 @@ public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase 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 }); + owner, other, mine, theirs); var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs index 88a12c1..eee38d9 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs @@ -16,9 +16,7 @@ public sealed class GetTracksQueryHandlerTests : HandlerTestBase 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 }); + owner, trackA, trackB); var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); Assert.Equal(2, result.TotalItemCount); @@ -32,9 +30,7 @@ public sealed class GetTracksQueryHandlerTests : HandlerTestBase 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 }); + owner, match, other); var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); var item = Assert.Single(result.Items); @@ -48,9 +44,7 @@ public sealed class GetTracksQueryHandlerTests : HandlerTestBase 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 }); + owner, rock, jazz); var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken); var item = Assert.Single(result.Items); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs index de49205..0db7d21 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs @@ -110,17 +110,4 @@ public sealed class RecordListeningProgressCommandHandlerTests : HandlerTestBase Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } - [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); - } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/StartListeningCommandHandlerTests.cs similarity index 75% rename from backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs rename to backend/Tests/Musify.Application.Tests/Tracks/StartListeningCommandHandlerTests.cs index e88e4e9..87bc5eb 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/StartListeningCommandHandlerTests.cs @@ -10,22 +10,21 @@ using Xunit; namespace Musify.Application.Tests.Tracks; -public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase +public sealed class StartListeningCommandHandlerTests : HandlerTestBase { private readonly IStreamTicketService ticketService = Substitute.For(); - private GetTrackStreamQueryHandler CreateHandler(PlaybackConfiguration? playback = null) + private StartListeningCommandHandler 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()); - return new GetTrackStreamQueryHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger()); + return new StartListeningCommandHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger()); } [Fact] @@ -35,7 +34,7 @@ public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase 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 StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.Equal("ticket-token", result.Value.Ticket); @@ -53,7 +52,7 @@ public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase [Fact] public async Task Handle_TrackMissing_ReturnsNotFound() { - var result = await CreateHandler().Handle(new GetTrackStreamQuery(Guid.NewGuid(), 1), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new StartListeningCommand(Guid.NewGuid(), 1), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -65,7 +64,7 @@ public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase 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 StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Conflict, result.FirstError.Type); } @@ -79,7 +78,7 @@ public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase 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 StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); } @@ -93,7 +92,7 @@ public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase 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 StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.Equal("ticket-token", result.Value.Ticket); diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 1fb9389..16a265e 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -142,6 +142,23 @@ export interface paths { patch?: never; trace?: never; }; + "/albums/{id}/tracks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Add Your Uploaded Tracks To An Album, Skipping The Ones It Already Has. */ + post: operations["AddAlbumTracks"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/albums/{albumId}/tracks/{trackId}": { parameters: { query?: never; @@ -151,8 +168,7 @@ export interface paths { }; get?: never; put?: never; - /** Add One Of Your Uploaded Tracks To An Album. */ - post: operations["AddTrackToAlbum"]; + post?: never; /** Remove A Track From An Album. */ delete: operations["RemoveTrackFromAlbum"]; options?: never; @@ -323,8 +339,7 @@ export interface paths { /** Get Paginated Tracks Of A PlayList. */ get: operations["GetPlayListTracks"]; put?: never; - /** Add One Of Your Tracks To A PlayList. */ - post: operations["AddPlayListTrack"]; + post?: never; delete?: never; options?: never; head?: never; @@ -400,6 +415,23 @@ export interface paths { patch?: never; trace?: never; }; + "/playlists/{id}/tracks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Add Tracks To A PlayList, Skipping The Ones It Already Has. */ + post: operations["AddPlayListTracks"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/playlists/{playlistId}/tracks/{trackId}": { parameters: { query?: never; @@ -729,9 +761,8 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { - AddPlayListTrackRequest: { - /** Format: uuid */ - trackId: string; + AddTracksRequest: { + trackIds: string[]; }; AlbumApplicationResponse: { /** Format: uuid */ @@ -810,8 +841,7 @@ export interface components { MixApplicationResponse: { /** Format: uuid */ id: string; - title: string; - subtitle: null | string; + kind: components["schemas"]["MixKind"]; /** Format: int32 */ itemCount: number | string; items: components["schemas"]["MixItemApplicationResponse"][]; @@ -826,6 +856,8 @@ export interface components { /** Format: int32 */ listensCount: number | string; }; + /** @enum {unknown} */ + MixKind: "Discovery" | "Daily"; PaginatedResponseOfAlbumApplicationResponse: { items: components["schemas"]["AlbumApplicationResponse"][]; /** Format: int32 */ @@ -1157,7 +1189,7 @@ export interface operations { }; header?: never; path: { - userId: number | string; + userId: unknown; }; cookie?: never; }; @@ -1415,17 +1447,20 @@ export interface operations { }; }; }; - AddTrackToAlbum: { + AddAlbumTracks: { parameters: { query?: never; header?: never; path: { - albumId: string; - trackId: string; + id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AddTracksRequest"]; + }; + }; responses: { /** @description No Content */ 204: { @@ -1434,6 +1469,15 @@ export interface operations { }; content?: never; }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; /** @description Unauthorized */ 401: { headers: { @@ -1735,7 +1779,7 @@ export interface operations { }; header?: never; path: { - userId: number | string; + userId: unknown; }; cookie?: never; }; @@ -1784,60 +1828,6 @@ export interface operations { }; }; }; - AddPlayListTrack: { - parameters: { - query?: never; - header?: never; - path: { - playlistId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AddPlayListTrackRequest"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["TrackApplicationResponse"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; GetPlayListCover: { parameters: { query?: { @@ -2058,6 +2048,67 @@ export interface operations { }; }; }; + AddPlayListTracks: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddTracksRequest"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; RemoveTrackFromPlayList: { parameters: { query?: never; @@ -2329,7 +2380,7 @@ export interface operations { }; header?: never; path: { - userId: number | string; + userId: unknown; }; cookie?: never; }; @@ -2477,7 +2528,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2506,7 +2557,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2535,7 +2586,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2571,7 +2622,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2600,7 +2651,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2632,7 +2683,7 @@ export interface operations { }; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2671,7 +2722,7 @@ export interface operations { }; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2700,7 +2751,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2729,7 +2780,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2772,7 +2823,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; diff --git a/web-player/src/lib/data/mixes.ts b/web-player/src/lib/data/mixes.ts index aa8d2ee..a2b5be5 100644 --- a/web-player/src/lib/data/mixes.ts +++ b/web-player/src/lib/data/mixes.ts @@ -4,6 +4,21 @@ import type { ApiTrackLike } from '$lib/player/player.svelte'; export type Mix = components['schemas']['MixApplicationResponse']; export type MixItem = components['schemas']['MixItemApplicationResponse']; +const MIX_TEXTS: Record = { + Discovery: { + title: 'Descubrimiento', + subtitle: 'Canciones de tu biblioteca que todavía no has escuchado.' + }, + Daily: { + title: 'Tu mezcla diaria', + subtitle: 'Lo que más escuchas, con alguna sorpresa de tu biblioteca.' + } +}; + +export function mixText(mix: Pick): { title: string; subtitle: string } { + return MIX_TEXTS[mix.kind]; +} + export function mixItemKey(item: MixItem): string { return `mf:${item.trackId}`; } diff --git a/web-player/src/lib/server/playlistActions.ts b/web-player/src/lib/server/playlistActions.ts index 318b4ef..2f93141 100644 --- a/web-player/src/lib/server/playlistActions.ts +++ b/web-player/src/lib/server/playlistActions.ts @@ -15,15 +15,12 @@ export async function addTrackAction({ request, params, locals, fetch }: Request } const api = createApiClient({ fetch, accessToken }); - const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', { - params: { path: { playlistId } }, - body: { trackId } + const { error: err } = await api.POST('/playlists/{id}/tracks', { + params: { path: { id: playlistId } }, + body: { trackIds: [trackId] } }); if (err) { - if (response?.status === 409) { - return fail(409, { message: 'La canción ya está en esa playlist.', trackId }); - } return fail(502, { message: 'No se pudo añadir la canción.', trackId }); } @@ -51,14 +48,18 @@ export async function addAlbumToPlaylistAction({ request, params, locals, fetch return fail(502, { message: 'No se pudo cargar el álbum.', albumId }); } - let added = 0; - for (const track of tracks.items) { - const { error: err } = await api.POST('/playlists/{playlistId}/tracks', { - params: { path: { playlistId } }, - body: { trackId: String(track.id) } - }); - if (!err) added++; + const trackIds = tracks.items.map((track) => String(track.id)); + if (trackIds.length === 0) { + return { addedAlbum: true, albumId, playlistId, added: 0 }; } - return { addedAlbum: true, albumId, playlistId, added }; + const { error: addErr } = await api.POST('/playlists/{id}/tracks', { + params: { path: { id: playlistId } }, + body: { trackIds } + }); + if (addErr) { + return fail(502, { message: 'No se pudo añadir el álbum.', albumId }); + } + + return { addedAlbum: true, albumId, playlistId, added: trackIds.length }; } diff --git a/web-player/src/routes/(app)/albums/[id]/+page.server.ts b/web-player/src/routes/(app)/albums/[id]/+page.server.ts index f77c3dc..e3d2b68 100644 --- a/web-player/src/routes/(app)/albums/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/albums/[id]/+page.server.ts @@ -54,8 +54,9 @@ export const actions: Actions = { if (!trackId) return fail(400, { message: 'Falta la canción.' }); const api = createApiClient({ fetch, accessToken }); - const result = await api.POST('/albums/{albumId}/tracks/{trackId}', { - params: { path: { albumId: params.id, trackId } } + const result = await api.POST('/albums/{id}/tracks', { + params: { path: { id: params.id } }, + body: { trackIds: [trackId] } }); const failure = unwrapOrFail(result, 'No se pudo añadir la canción.'); if (failure) return failure; diff --git a/web-player/src/routes/(app)/mixes/[id]/+page.svelte b/web-player/src/routes/(app)/mixes/[id]/+page.svelte index 6f1c7a2..c08cdd6 100644 --- a/web-player/src/routes/(app)/mixes/[id]/+page.svelte +++ b/web-player/src/routes/(app)/mixes/[id]/+page.svelte @@ -18,11 +18,12 @@ import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte'; import ListPlus from '@lucide/svelte/icons/list-plus'; import { createTrackMenu } from '$lib/state/menus.svelte'; - import { mixItemTrack } from '$lib/data/mixes'; + import { mixItemTrack, mixText } from '$lib/data/mixes'; let { data, form } = $props(); const mix = $derived(data.mix); + const text = $derived(mixText(mix)); const items = $derived(mix.items); const tracks = $derived(items.map(mixItemTrack)); const queue = $derived(tracks.map(toQueueItem)); @@ -49,8 +50,8 @@ - {mix.title} - + {text.title} + @@ -58,15 +59,15 @@ {#snippet cover()} item.trackId)} size="hero" - alt={mix.title} + alt={text.title} class="shrink-0" /> {/snippet} diff --git a/web-player/src/routes/+page.svelte b/web-player/src/routes/+page.svelte index 906c6e6..498129a 100644 --- a/web-player/src/routes/+page.svelte +++ b/web-player/src/routes/+page.svelte @@ -14,7 +14,7 @@ } from '$lib/player/player.svelte'; import { pressable } from '$lib/actions/pressable'; import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed'; - import { mixItemTrack } from '$lib/data/mixes'; + import { mixItemTrack, mixText } from '$lib/data/mixes'; import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/utils/format'; import Page from '$lib/components/ui/layout/Page.svelte'; import Artwork from '$lib/components/ui/media/Artwork.svelte'; @@ -279,8 +279,8 @@ {#each mixes as mix, i (mix.id)} item.trackId)} onPlay={(event) => playMix(mix, event)} index={i} From 0cba32f77612c850be2fadce2e4b0743f6d11005 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 12:18:57 +0200 Subject: [PATCH 11/21] Simplify infrastructure services and move all recurring jobs to Hangfire --- .../Contracts/IStorageService.cs | 3 +- .../Mixes/GenerateMixesForUser.cs | 13 +- .../Services/UploadIntentIssuer.cs | 2 +- .../UploadIntents/DeleteStaleTempUploads.cs | 38 ++++ .../UploadIntents/ExpireUploadIntents.cs | 46 +++++ .../Jobs/DailyMixGenerationJob.cs | 9 +- .../Jobs/RecurringJobsRegistrar.cs | 41 +++++ .../Jobs/TempUploadsCleanupJob.cs | 10 ++ .../Jobs/TemporalUploadsCleanupJob.cs | 70 -------- .../Jobs/UploadIntentExpirationJob.cs | 70 +------- .../MassTransit/MassTransitEventBus.cs | 8 +- .../Services/AudioTranscoderService.cs | 106 ++++------- .../Services/PictureService.cs | 36 ++-- .../Services/ServicesDependencyInjection.cs | 19 +- .../Services/StorageService.cs | 166 ++++++------------ .../Authentication/JwtBearerEventsHandler.cs | 23 ++- backend/Hosts/Musify.Worker/Program.cs | 21 +-- .../Endpoints/AlbumEndpointsTests.cs | 2 +- ...stAlbumPictureUploadCommandHandlerTests.cs | 2 +- ...GenerateMixesForUserCommandHandlerTests.cs | 12 +- ...layListPictureUploadCommandHandlerTests.cs | 2 +- ...questTrackUploadUrlsCommandHandlerTests.cs | 2 +- .../UploadIntentJobsCommandHandlerTests.cs | 71 ++++++++ .../Services/StorageServiceTests.cs | 2 +- 24 files changed, 361 insertions(+), 413 deletions(-) create mode 100644 backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs create mode 100644 backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs create mode 100644 backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs create mode 100644 backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs delete mode 100644 backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs create mode 100644 backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs diff --git a/backend/Core/Musify.Application/Contracts/IStorageService.cs b/backend/Core/Musify.Application/Contracts/IStorageService.cs index c5c0a70..90a8601 100644 --- a/backend/Core/Musify.Application/Contracts/IStorageService.cs +++ b/backend/Core/Musify.Application/Contracts/IStorageService.cs @@ -2,11 +2,12 @@ namespace Musify.Application.Contracts; public interface IStorageService { + /// Opens an object for reading, or returns null when it does not exist. public Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); public Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); - public Task 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, bool preventOverwrite = true, CancellationToken cancellationToken = default); public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken); diff --git a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs index 38e16ff..e5c6a26 100644 --- a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs +++ b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -1,4 +1,3 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -10,17 +9,17 @@ using Musify.Domain.ValueObjects; namespace Musify.Application.Mixes; public record GenerateMixesForUserCommand(long UserId) - : ICommand>; + : ICommand; public class GenerateMixesForUserCommandHandler( IDatabase database, MixConfiguration mixConfiguration, ILogger logger) - : ICommandHandler> + : ICommandHandler { private sealed record MixDraft(MixKind Kind, IReadOnlyList TrackIds); - public async ValueTask> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) + public async ValueTask Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) { var userId = request.UserId; @@ -33,7 +32,7 @@ public class GenerateMixesForUserCommandHandler( if (libraryTrackIds.Count == 0) { logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId); - return Result.Success; + return Unit.Value; } var historyTrackIds = await database.ListeningHistories @@ -63,7 +62,7 @@ public class GenerateMixesForUserCommandHandler( if (drafts.Count == 0) { logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId); - return Result.Success; + return Unit.Value; } await ReplaceMixesAsync(userId, drafts, cancellationToken); @@ -71,7 +70,7 @@ public class GenerateMixesForUserCommandHandler( await database.SaveChangesAsync(cancellationToken); logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId); - return Result.Success; + return Unit.Value; } private List Take(IEnumerable trackIds) => diff --git a/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs b/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs index 18660f5..70adca1 100644 --- a/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs @@ -69,7 +69,7 @@ public sealed class UploadIntentIssuer( foreach (var item in planned) { var uploadUrl = await storageService.GetUploadUrlAsync( - storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken); + storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken: cancellationToken); var intent = new UploadIntent { diff --git a/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs b/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs new file mode 100644 index 0000000..05fc48d --- /dev/null +++ b/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs @@ -0,0 +1,38 @@ +using Mediator; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts; + +namespace Musify.Application.UploadIntents; + +/// Deletes temporary upload objects that nobody claimed within the retention window. +public record DeleteStaleTempUploadsCommand : ICommand; + +public class DeleteStaleTempUploadsCommandHandler( + IStorageService storageService, + ApplicationStorageConfiguration storageConfiguration, + UploadIntentConfiguration uploadIntentConfiguration, + ILogger logger) + : ICommandHandler +{ + public async ValueTask Handle(DeleteStaleTempUploadsCommand request, CancellationToken cancellationToken) + { + var cutOff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TempUploadsRetentionDays); + var deleted = 0; + + await foreach (var (key, lastModified) in storageService.ListObjectsAsync( + storageConfiguration.Bucket, uploadIntentConfiguration.TempRootPrefix, cancellationToken)) + { + if (lastModified >= cutOff) + continue; + + await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken); + deleted++; + } + + if (deleted > 0) + logger.LogInformation("Deleted {Deleted} orphaned temp uploads", deleted); + + return deleted; + } +} diff --git a/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs b/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs new file mode 100644 index 0000000..9b6e3d2 --- /dev/null +++ b/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs @@ -0,0 +1,46 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.UploadIntents; + +/// Marks overdue upload intents as expired and deletes the ones expired for longer than the retention window. +public record ExpireUploadIntentsCommand : ICommand; + +public class ExpireUploadIntentsCommandHandler( + IDatabase database, + UploadIntentConfiguration uploadIntentConfiguration, + ILogger logger) + : ICommandHandler +{ + public async ValueTask Handle(ExpireUploadIntentsCommand request, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + + var expiredIntents = await database.UploadIntents + .Where(intent => intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt < now) + .ToListAsync(cancellationToken); + + foreach (var intent in expiredIntents) + intent.Status = UploadIntentStatus.Expired; + + var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays); + var oldExpiredIntents = await database.UploadIntents + .Where(intent => intent.Status == UploadIntentStatus.Expired && intent.ExpiresAt < retentionCutoff) + .ToListAsync(cancellationToken); + + database.UploadIntents.RemoveRange(oldExpiredIntents); + + if (expiredIntents.Count == 0 && oldExpiredIntents.Count == 0) + return 0; + + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Marked {Expired} upload intents as expired and deleted {Deleted} old ones", + expiredIntents.Count, oldExpiredIntents.Count); + + return expiredIntents.Count + oldExpiredIntents.Count; + } +} diff --git a/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs index a0128b3..d610c1e 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs @@ -23,14 +23,7 @@ public class DailyMixGenerationJob( 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) - { - logger.LogWarning("Failed to generate mixes for user {UserId}: {Error}", - userId, result.FirstError.Description); - } - } + await mediator.Send(new GenerateMixesForUserCommand(userId), cancellationToken); logger.LogInformation("Finished daily mix generation"); } diff --git a/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs b/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs new file mode 100644 index 0000000..ed278f6 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs @@ -0,0 +1,41 @@ +using Hangfire; +using Microsoft.Extensions.Hosting; +using Musify.Application.Configuration; + +namespace Musify.Infrastructure.Jobs; + +/// Registers every recurring Hangfire job when the host starts. +public sealed class RecurringJobsRegistrar( + IRecurringJobManager recurringJobManager, + UploadIntentConfiguration uploadIntentConfiguration) + : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + var options = new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }; + + recurringJobManager.AddOrUpdate( + "daily-mix-generation", job => job.RunAsync(CancellationToken.None), Cron.Daily(), options); + recurringJobManager.AddOrUpdate( + "listening-history-cleanup", job => job.RunAsync(CancellationToken.None), Cron.Daily(3), options); + recurringJobManager.AddOrUpdate( + "upload-intent-expiration", job => job.RunAsync(CancellationToken.None), + EveryCron(uploadIntentConfiguration.ExpirationJobIntervalSeconds), options); + recurringJobManager.AddOrUpdate( + "temp-uploads-cleanup", job => job.RunAsync(CancellationToken.None), + EveryCron(uploadIntentConfiguration.TempCleanupJobIntervalSeconds), options); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// Hangfire cron has a one-minute resolution, so the interval is rounded up to whole minutes. + internal static string EveryCron(int intervalSeconds) + { + var minutes = Math.Max(1, (int)Math.Ceiling(intervalSeconds / 60.0)); + return minutes < 60 + ? $"*/{minutes} * * * *" + : $"0 */{Math.Max(1, minutes / 60)} * * *"; + } +} diff --git a/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs b/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs new file mode 100644 index 0000000..5295904 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs @@ -0,0 +1,10 @@ +using Mediator; +using Musify.Application.UploadIntents; + +namespace Musify.Infrastructure.Jobs; + +public class TempUploadsCleanupJob(IMediator mediator) +{ + public async Task RunAsync(CancellationToken cancellationToken) => + await mediator.Send(new DeleteStaleTempUploadsCommand(), cancellationToken); +} diff --git a/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs b/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs deleted file mode 100644 index d866c1f..0000000 --- a/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs +++ /dev/null @@ -1,70 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Contracts; - -namespace Musify.Infrastructure.Jobs; - -public class TemporalUploadsCleanUpJob( - IServiceScopeFactory scopeFactory, - ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : BackgroundService -{ - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - logger.LogInformation("TemporalUploadsCleanUpJob started (interval: {Interval}s, prefix: {Prefix})", - uploadIntentConfiguration.TempCleanupJobIntervalSeconds, - uploadIntentConfiguration.TempRootPrefix); - - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(uploadIntentConfiguration.TempCleanupJobIntervalSeconds)); - - do - { - try - { - 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.TempUploadsRetentionDays); - var deleted = 0; - var errors = 0; - - await foreach (var (key, lastModified) in storageService.ListObjectsAsync( - storageConfiguration.Bucket, - uploadIntentConfiguration.TempRootPrefix, - 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); - } -} diff --git a/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs b/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs index 40cf862..3a6d37d 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs @@ -1,70 +1,10 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; +using Mediator; +using Musify.Application.UploadIntents; namespace Musify.Infrastructure.Jobs; -public class UploadIntentExpirationJob( - IServiceScopeFactory scopeFactory, - ILogger logger, - UploadIntentConfiguration uploadIntentConfiguration) - : BackgroundService +public class UploadIntentExpirationJob(IMediator mediator) { - 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 - { - try - { - await RunAsync(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); - } - - 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); - } - } + public async Task RunAsync(CancellationToken cancellationToken) => + await mediator.Send(new ExpireUploadIntentsCommand(), cancellationToken); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs b/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs index 94325d5..a10b3d4 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs @@ -6,10 +6,6 @@ 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)); - - await publishEndpoint.Publish(message, cancellationToken); - } + public Task PublishAsync(T message, CancellationToken cancellationToken) where T : class => + publishEndpoint.Publish(message, cancellationToken); } diff --git a/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs b/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs index bc13de5..c671eb3 100644 --- a/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs +++ b/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs @@ -7,53 +7,41 @@ using Musify.Infrastructure.Configuration; namespace Musify.Infrastructure.Services; -public partial class AudioService(ILogger logger, +public sealed partial class AudioTranscoderService( + ILogger logger, AudioConfiguration audioConfiguration) : IAudioTranscoderService { - private record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError); + private sealed record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError); [GeneratedRegex(@"Duration:\s*(\d+):(\d{2}):(\d{2}(?:\.\d+)?)", RegexOptions.IgnoreCase)] private static partial Regex DurationRegex(); public async Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken) { - try + Directory.CreateDirectory(destinationPath); + + logger.LogInformation("Starting audio transcoding for {DestinationPath}", destinationPath); + + var executionResult = await ExecuteFfmpegAsync( + BuildAudioFileArguments(destinationPath, audioConfiguration.Ffmpeg), + destinationPath, + audioStream, + "TranscodeToAudioFile", + cancellationToken); + + if (executionResult.ExitCode != 0) { - Directory.CreateDirectory(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; + 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); } private async Task GetAudioDurationAsync(string filePath, CancellationToken cancellationToken) @@ -61,6 +49,7 @@ public partial class AudioService(ILogger logger, var executionResult = await ExecuteFfmpegAsync( BuildProbeDurationArguments(filePath), Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory, + input: null, "ProbeAudioDuration", cancellationToken); @@ -75,37 +64,14 @@ public partial class AudioService(ILogger logger, return new TimeSpan(hours, minutes, 0) + TimeSpan.FromSeconds(seconds); } - 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, + Stream? input, string operationName, CancellationToken cancellationToken) { - using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: false); + using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: input != null); process.Start(); logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId}) in {WorkingDirectory}", operationName, process.Id, workingDirectory); @@ -113,6 +79,15 @@ public partial class AudioService(ILogger logger, var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + if (input != null) + { + if (input.CanSeek) + input.Position = 0; + + await input.CopyToAsync(process.StandardInput.BaseStream, cancellationToken); + process.StandardInput.Close(); + } + return await WaitForProcessResultAsync(process, outputTask, errorTask, operationName, cancellationToken); } @@ -148,11 +123,8 @@ public partial class AudioService(ILogger logger, } 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}."); + throw new TimeoutException($"ffmpeg execution exceeded timeout {audioConfiguration.TranscodingTimeout} (PID: {process.Id})."); } var standardError = await errorTask; @@ -171,9 +143,9 @@ public partial class AudioService(ILogger logger, if (!process.HasExited) process.Kill(); } - catch (Exception exception) + catch (InvalidOperationException exception) { - logger.LogError(exception, "Failed to kill ffmpeg process (PID: {ProcessId})", process.Id); + logger.LogWarning(exception, "Could not kill ffmpeg process (PID: {ProcessId}); it had already exited", process.Id); } } diff --git a/backend/Core/Musify.Infrastructure/Services/PictureService.cs b/backend/Core/Musify.Infrastructure/Services/PictureService.cs index 7b7e182..e938bb8 100644 --- a/backend/Core/Musify.Infrastructure/Services/PictureService.cs +++ b/backend/Core/Musify.Infrastructure/Services/PictureService.cs @@ -10,30 +10,22 @@ public class PictureService(ILogger logger) { public async Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken) { - 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; } } diff --git a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index e1ce0c5..0ad4da5 100644 --- a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -28,7 +28,7 @@ public static class ServicesDependencyInjection return new AmazonS3Client(configuration.AccessKey, configuration.SecretAccessKey, s3Configuration); }); - services.AddScoped(); + services.AddSingleton(); return services; } @@ -45,17 +45,7 @@ public static class ServicesDependencyInjection return services; } - public static IServiceCollection AddUploadIntentJobs(this IServiceCollection services, IConfiguration configuration) - { - services.AddUploadIntentConfiguration(configuration); - - services.AddHostedService(); - services.AddHostedService(); - - return services; - } - - public static IServiceCollection AddDailyMixGenerationJob(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection AddJobs(this IServiceCollection services) { services.AddHangfire((serviceProvider, hangfireConfiguration) => { @@ -67,6 +57,9 @@ public static class ServicesDependencyInjection services.AddHangfireServer(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddHostedService(); return services; } @@ -83,7 +76,7 @@ public static class ServicesDependencyInjection { services.AddValidatedOptions(configuration); - services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/backend/Core/Musify.Infrastructure/Services/StorageService.cs b/backend/Core/Musify.Infrastructure/Services/StorageService.cs index 57fbecc..a44fb6c 100644 --- a/backend/Core/Musify.Infrastructure/Services/StorageService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StorageService.cs @@ -1,29 +1,27 @@ +using System.Runtime.CompilerServices; using Amazon.S3; using Amazon.S3.Model; using Amazon.S3.Transfer; using Microsoft.Extensions.Logging; using MimeMapping; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Infrastructure.Configuration; -using ObjectMetaData = Musify.Application.Contracts.ObjectMetaData; namespace Musify.Infrastructure.Services; -public class StorageService(IAmazonS3 amazonS3, ILogger logger, +public sealed class StorageService( + IAmazonS3 amazonS3, + ILogger logger, InfrastructureStorageConfiguration storageClientConfiguration) : IStorageService { public async Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken) { - var request = new GetObjectRequest - { - BucketName = bucket, - Key = key, - }; - try { - var response = await amazonS3.GetObjectAsync(request, cancellationToken); + var response = await amazonS3.GetObjectAsync( + new GetObjectRequest { BucketName = bucket, Key = key }, cancellationToken); logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key); return response.ResponseStream; } @@ -32,11 +30,6 @@ public class StorageService(IAmazonS3 amazonS3, ILogger logger, logger.LogDebug("File not found in S3 {Bucket}/{Key}", bucket, key); return null; } - catch (Exception exception) - { - logger.LogError(exception, "Failed to get file from S3 {Bucket}/{Key}", bucket, key); - throw; - } } public async Task GetUploadUrlAsync( @@ -44,8 +37,8 @@ public class StorageService(IAmazonS3 amazonS3, ILogger logger, string key, string contentType, TimeSpan expirationTime, - CancellationToken cancellationToken, - bool preventOverwrite = true) + bool preventOverwrite = true, + CancellationToken cancellationToken = default) { var request = new GetPreSignedUrlRequest { @@ -71,140 +64,81 @@ public class StorageService(IAmazonS3 amazonS3, ILogger logger, 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) + catch (AmazonS3Exception exception) when (exception.StatusCode == System.Net.HttpStatusCode.NotFound) { return null; } - catch (Exception exception) - { - logger.LogError(exception, "HEAD failed for {Bucket}/{Key}", bucket, key); - throw; - } } public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken) { - var request = new DeleteObjectRequest - { - BucketName = bucket, - Key = key, - }; - logger.LogDebug("Removing file from S3 {Bucket}/{Key}", bucket, key); - await amazonS3.DeleteObjectAsync(request, cancellationToken); + await amazonS3.DeleteObjectAsync(new DeleteObjectRequest { BucketName = bucket, Key = key }, cancellationToken); } - public async Task> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken) + public async Task> TransferFilesAsync( + string sourceDirectory, string bucket, string route, CancellationToken cancellationToken) { - var trasnsferUtility = new TransferUtility(amazonS3); - - var files = Directory.GetFiles(sourceDirectory, "*"); + using var transferUtility = new TransferUtility(amazonS3); var uploadedKeys = new List(); - var successCount = 0; - var failedCount = 0; - foreach (var file in files) + foreach (var filePath in Directory.GetFiles(sourceDirectory, "*")) { - string fileName = Path.GetFileName(file); - string filePath = Path.Combine(sourceDirectory, file); + var key = StorageKey.Combine(route, Path.GetFileName(filePath)); - var key = string.Join('/', new[] { route, fileName } - .Where(static s => !string.IsNullOrWhiteSpace(s)) - .Select(static s => s.Trim().Trim('/', '\\'))); - var contentType = MimeUtility.GetMimeMapping(filePath); - try - { - await trasnsferUtility.UploadAsync( - new TransferUtilityUploadRequest - { - FilePath = filePath, - BucketName = bucket, - Key = key, - ContentType = contentType - }, - cancellationToken); - uploadedKeys.Add(key); - successCount++; - logger.LogDebug("Transferred file to S3 {Bucket}/{Key}", bucket, key); - } - catch (Exception exception) - { - failedCount++; - logger.LogError(exception, "Failed to transfer file to S3 {Bucket}/{Key}", bucket, key); - throw; - } + await transferUtility.UploadAsync( + new TransferUtilityUploadRequest + { + FilePath = filePath, + BucketName = bucket, + Key = key, + ContentType = MimeUtility.GetMimeMapping(filePath) + }, + cancellationToken); + + uploadedKeys.Add(key); + logger.LogDebug("Transferred file to S3 {Bucket}/{Key}", bucket, key); } - logger.LogInformation("File transfer completed to {Bucket}/{Route}. Success: {SuccessCount}, Failed: {FailedCount}", bucket, route, successCount, failedCount); + logger.LogInformation("Transferred {Count} files to {Bucket}/{Route}", uploadedKeys.Count, bucket, route); return uploadedKeys; } public async Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken) { - 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; - } + await using var fileStream = File.OpenRead(filePath); + await UploadFileAsync(fileStream, MimeUtility.GetMimeMapping(filePath), bucket, key, cancellationToken); } public async Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken) { - var request = new PutObjectRequest - { - BucketName = bucket, - ContentType = contentType, - InputStream = sourceStream, - Key = key - }; - - try - { - 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); - throw; - } + await amazonS3.PutObjectAsync( + new PutObjectRequest { BucketName = bucket, ContentType = contentType, InputStream = sourceStream, Key = key }, + cancellationToken); + logger.LogDebug("Uploaded file to S3 {Bucket}/{Key}", bucket, key); } - public async Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, - CancellationToken cancellationToken) + 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 - }; + await amazonS3.CopyObjectAsync( + new CopyObjectRequest + { + SourceBucket = sourceBucket, + SourceKey = sourceKey, + DestinationBucket = destinationBucket, + DestinationKey = destinationKey + }, + cancellationToken); - try - { - 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; - } + logger.LogDebug("Copied file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", + sourceBucket, sourceKey, destinationBucket, destinationKey); } public async IAsyncEnumerable ListObjectsAsync( - string bucket, string prefix, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + string bucket, string prefix, [EnumeratorCancellation] CancellationToken cancellationToken) { var listRequest = new ListObjectsV2Request { @@ -221,6 +155,6 @@ public class StorageService(IAmazonS3 amazonS3, ILogger logger, listRequest.ContinuationToken = listResponse.NextContinuationToken; } - while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value); + while (listResponse.IsTruncated == true); } } diff --git a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs index c43a3ac..6f65216 100644 --- a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs +++ b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using Mediator; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Caching.Memory; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Musify.Application.Users; namespace Musify.Api.Authentication; @@ -87,15 +88,15 @@ public sealed class JwtBearerEventsHandler( try { + var userInfoEndpoint = await ResolveUserInfoEndpointAsync(tokenValidatedContext, cancellationToken); + var client = httpClientFactory.CreateClient(); - var request = new HttpRequestMessage( - HttpMethod.Get, - $"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo") + using var request = new HttpRequestMessage(HttpMethod.Get, userInfoEndpoint) { Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) } }; - var response = await client.SendAsync(request, cancellationToken); + using var response = await client.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { logger.LogDebug("Userinfo request returned {StatusCode}", response.StatusCode); @@ -111,6 +112,20 @@ public sealed class JwtBearerEventsHandler( } } + private async Task ResolveUserInfoEndpointAsync( + TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken) + { + var configurationManager = tokenValidatedContext.Options.ConfigurationManager; + if (configurationManager != null) + { + var configuration = await configurationManager.GetConfigurationAsync(cancellationToken); + if (configuration is OpenIdConnectConfiguration { UserInfoEndpoint: { Length: > 0 } endpoint }) + return endpoint; + } + + return $"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo"; + } + public override Task AuthenticationFailed(AuthenticationFailedContext context) { logger.LogWarning(context.Exception, "JWT authentication failed"); diff --git a/backend/Hosts/Musify.Worker/Program.cs b/backend/Hosts/Musify.Worker/Program.cs index 30947c4..0ea3523 100644 --- a/backend/Hosts/Musify.Worker/Program.cs +++ b/backend/Hosts/Musify.Worker/Program.cs @@ -1,6 +1,4 @@ -using Hangfire; using Musify.Application.Configuration; -using Musify.Infrastructure.Jobs; using Musify.Infrastructure.MassTransit; using Musify.Infrastructure.Persistence; using Musify.Infrastructure.Services; @@ -26,25 +24,10 @@ services.AddAudioTranscoder(configuration); services.AddPictureService(); services.AddDatabase(configuration); services.AddMassTransitConsumers(configuration); -services.AddUploadIntentJobs(configuration); +services.AddUploadIntentConfiguration(configuration); services.AddMediator(); -services.AddDailyMixGenerationJob(configuration); +services.AddJobs(); var host = builder.Build(); -using (var scope = host.Services.CreateScope()) -{ - var recurringJobManager = scope.ServiceProvider.GetRequiredService(); - recurringJobManager.AddOrUpdate( - "daily-mix-generation", - job => job.RunAsync(CancellationToken.None), - Cron.Daily(), - new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); - recurringJobManager.AddOrUpdate( - "listening-history-cleanup", - job => job.RunAsync(CancellationToken.None), - Cron.Daily(3), - new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); -} - host.Run(); diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index 793eca0..6f6d5eb 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -18,7 +18,7 @@ public sealed class AlbumEndpointsTests(ApiTestFixture fixture) private async Task CreatePictureIntentAsync(HttpClient client) { fixture.StorageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://storage.musify.test/presigned-upload"); fixture.StorageService .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) diff --git a/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs index 05d4a2e..98da07e 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs @@ -17,7 +17,7 @@ public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBa private RequestAlbumPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) { storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://storage.musify.test/presigned-upload"); var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs index e7eb3c6..0a30e30 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs @@ -17,9 +17,7 @@ public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase var user = TestEntities.User(); await SeedAsync(user); - var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken)); } @@ -32,9 +30,7 @@ public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase user, track, TestEntities.ListeningHistory(user.Id, track.Id)); - var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); Assert.Single(mixes); Assert.Equal(MixKind.Daily, mixes[0].Kind); @@ -54,9 +50,7 @@ public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase user, listenedTrack, unheardTrack, staleMix, staleItem, TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); - var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); Assert.DoesNotContain(mixes, mix => mix.Id == staleMix.Id); Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery); diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs index d08a8ae..928762e 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs @@ -17,7 +17,7 @@ public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTes private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) { storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://storage.musify.test/presigned-upload"); var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs index 6eb2e52..33376ef 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs @@ -18,7 +18,7 @@ public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) { storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://storage.musify.test/presigned-upload"); var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); diff --git a/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs new file mode 100644 index 0000000..45f604b --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Tests.TestSupport; +using Musify.Application.UploadIntents; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.UploadIntents; + +public sealed class UploadIntentJobsCommandHandlerTests : HandlerTestBase +{ + [Fact] + public async Task ExpireUploadIntents_OverdueIssuedIntent_IsMarkedExpired() + { + var owner = TestEntities.User(); + var overdue = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-5)); + var active = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(5)); + await SeedAsync(owner, overdue, active); + var handler = new ExpireUploadIntentsCommandHandler( + Database, TestConfigurations.UploadIntent(), NoOpLogger()); + + var changed = await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken); + + Assert.Equal(1, changed); + var stored = await Database.UploadIntents.AsNoTracking().ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(UploadIntentStatus.Expired, stored.Single(intent => intent.Id == overdue.Id).Status); + Assert.Equal(UploadIntentStatus.Issued, stored.Single(intent => intent.Id == active.Id).Status); + } + + [Fact] + public async Task ExpireUploadIntents_IntentExpiredLongAgo_IsDeleted() + { + var owner = TestEntities.User(); + var old = TestEntities.UploadIntent( + owner.Id, status: UploadIntentStatus.Expired, expiresAt: DateTime.UtcNow.AddDays(-30)); + await SeedAsync(owner, old); + var handler = new ExpireUploadIntentsCommandHandler( + Database, TestConfigurations.UploadIntent(), NoOpLogger()); + + await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken); + + Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteStaleTempUploads_RemovesOnlyObjectsOlderThanTheRetention() + { + var storage = Substitute.For(); + storage.ListObjectsAsync("test-bucket", "temp", Arg.Any()).Returns(Objects( + new StorageObject("temp/old", DateTime.UtcNow.AddDays(-10)), + new StorageObject("temp/new", DateTime.UtcNow))); + var handler = new DeleteStaleTempUploadsCommandHandler( + storage, TestConfigurations.Storage("test-bucket"), TestConfigurations.UploadIntent(), + NoOpLogger()); + + var deleted = await handler.Handle(new DeleteStaleTempUploadsCommand(), TestContext.Current.CancellationToken); + + Assert.Equal(1, deleted); + await storage.Received(1).RemoveFileAsync("test-bucket", "temp/old", Arg.Any()); + await storage.DidNotReceive().RemoveFileAsync("test-bucket", "temp/new", Arg.Any()); + } + + private static async IAsyncEnumerable Objects(params StorageObject[] objects) + { + foreach (var item in objects) + yield return item; + + await Task.CompletedTask; + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs index 7065485..f96cbba 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs @@ -117,7 +117,7 @@ public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAs var key = $"tests/{Guid.NewGuid():N}/presigned.txt"; var uploadUrl = await service.GetUploadUrlAsync( - InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken); + InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), cancellationToken: TestContext.Current.CancellationToken); using var httpClient = new HttpClient(); using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl) From 405df082cc14ecb36eb2c2a542baadc6c05e7dfb Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 12:25:55 +0200 Subject: [PATCH 12/21] Deduplicate MassTransit routing slips, activities and consumers --- .../Musify.Application/Contracts/IDatabase.cs | 2 + .../Albums/DeleteAlbumFromDbActivity.cs | 35 --- .../Albums/MarkAlbumAsFailedActivity.cs | 31 --- .../Albums/MarkAlbumAsRemovingActivity.cs | 36 --- .../GenerateAudioWorkflowPathsActivity.cs | 41 +--- .../Activities/Audio/TrackAudioStatus.cs | 24 ++ .../Audio/TranscodeAudioActivity.cs | 73 ++---- .../Audio/UpdateTrackAudioActivity.cs | 87 ++----- .../Files/CopyFileInBucketActivity.cs | 6 +- .../Files/DownloadFileFromBucketActivity.cs | 6 +- .../Files/RemoveFileFromBucketActivity.cs | 4 +- .../Files/TransferFilesToBucketActivity.cs | 10 +- .../Files/UploadFileToBucketActivity.cs | 8 +- .../LifeCycle/LifeCycleActivities.cs | 97 ++++++++ .../GeneratePictureWorkflowPathsActivity.cs | 53 ++--- .../Pictures/ResizePictureActivity.cs | 4 +- .../Pictures/UpdateAlbumPictureActivity.cs | 110 --------- .../Pictures/UpdatePicturesActivity.cs | 160 +++++++++++++ .../Pictures/UpdatePlayListPictureActivity.cs | 110 --------- .../Pictures/UpdateTrackPictureActivity.cs | 100 --------- .../PlayLists/DeletePlayListFromDbActivity.cs | 35 --- .../PlayLists/MarkPlayListAsFailedActivity.cs | 31 --- .../MarkPlayListAsRemovingActivity.cs | 36 --- .../Tracks/DeleteTrackFromDbActivity.cs | 35 --- .../Tracks/MarkTrackAsFailedActivity.cs | 31 --- .../Tracks/MarkTrackAsRemovingActivity.cs | 36 --- .../Arguments/DeleteAlbumFromDbArguments.cs | 4 - ...dArguments.cs => DeleteEntityArguments.cs} | 2 +- .../DeletePlayListFromDbArguments.cs | 4 - .../Arguments/DeleteTrackFromDbArguments.cs | 4 - .../Arguments/MarkAlbumAsFailedArguments.cs | 3 - .../Arguments/MarkAlbumAsRemovingArguments.cs | 4 - .../Arguments/MarkLifeCycleArguments.cs | 5 + .../MarkPlayListAsFailedArguments.cs | 3 - .../MarkPlayListAsRemovingArguments.cs | 4 - .../Arguments/MarkTrackAsRemovingArguments.cs | 4 - .../Arguments/UpdateAlbumPictureArguments.cs | 8 - ...rguments.cs => UpdatePicturesArguments.cs} | 4 +- .../UpdatePlayListPictureArguments.cs | 8 - .../AlbumProcessingFailedConsumer.cs | 29 +-- .../Consumers/CreateAlbumConsumer.cs | 14 +- .../Consumers/CreatePlayListConsumer.cs | 14 +- .../Consumers/CreateTrackConsumer.cs | 14 +- .../Consumers/DeleteAlbumConsumer.cs | 18 +- .../Consumers/DeletePlayListConsumer.cs | 18 +- .../Consumers/DeleteTrackConsumer.cs | 14 +- .../MassTransit/Consumers/EndpointHelper.cs | 8 - .../PlayListProcessingFailedConsumer.cs | 29 +-- .../Consumers/ProcessingFailedCleanup.cs | 34 +++ .../Consumers/ProcessingSlipFaultConsumer.cs | 1 + .../Consumers/RoutingSlipCleanUpConsumer.cs | 10 +- .../TrackProcessingFailedConsumer.cs | 38 +--- .../Consumers/UpdateAlbumPictureConsumer.cs | 14 +- .../UpdateAlbumPictureSourceConsumer.cs | 14 +- .../UpdatePlayListPictureConsumer.cs | 14 +- .../UpdatePlayListPictureSourceConsumer.cs | 14 +- .../Consumers/UpdateTrackAudioConsumer.cs | 14 +- .../Consumers/UpdateTrackPictureConsumer.cs | 14 +- ...TransitDependencyInjection.Registration.cs | 75 +++---- .../MassTransitDependencyInjection.cs | 57 ++--- .../MassTransit/Logs/UpdateAlbumPictureLog.cs | 8 - .../MassTransit/Logs/UpdatePicturesLog.cs | 8 + .../Logs/UpdatePlayListPictureLog.cs | 8 - .../MassTransit/Logs/UpdateTrackPictureLog.cs | 8 - .../AlbumPictureSourceRoutingSlipBuilder.cs | 87 ------- .../AudioWorkflowRoutingSlipBuilder.cs | 91 +++----- .../Builders/CreateTrackRoutingSlipBuilder.cs | 80 +++---- .../Builders/DeleteAlbumRoutingSlipBuilder.cs | 93 -------- .../DeletePlayListRoutingSlipBuilder.cs | 93 -------- .../Builders/DeleteRoutingSlipBuilder.cs | 135 +++++++++++ .../Builders/DeleteTrackRoutingSlipBuilder.cs | 106 --------- .../PictureSourceRoutingSlipBuilder.cs | 74 ++++++ .../PictureWorkflowRoutingSlipBuilder.cs | 212 +++++------------- ...PlayListPictureSourceRoutingSlipBuilder.cs | 87 ------- .../RoutingSlip/RoutingSlipVariableNames.cs | 2 +- .../MassTransit/RoutingSlip/RoutingSlips.cs | 48 ++++ 76 files changed, 922 insertions(+), 1933 deletions(-) delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/DeleteAlbumFromDbActivity.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsRemovingActivity.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TrackAudioStatus.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/LifeCycle/LifeCycleActivities.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePicturesActivity.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsFailedActivity.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsFailedActivity.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteAlbumFromDbArguments.cs rename backend/Core/Musify.Infrastructure/MassTransit/Arguments/{MarkTrackAsFailedArguments.cs => DeleteEntityArguments.cs} (50%) delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteTrackFromDbArguments.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsRemovingArguments.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkLifeCycleArguments.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsFailedArguments.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsRemovingArguments.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs rename backend/Core/Musify.Infrastructure/MassTransit/Arguments/{UpdateTrackPictureArguments.cs => UpdatePicturesArguments.cs} (75%) delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Consumers/EndpointHelper.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingFailedCleanup.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePicturesLog.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteAlbumRoutingSlipBuilder.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureSourceRoutingSlipBuilder.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs diff --git a/backend/Core/Musify.Application/Contracts/IDatabase.cs b/backend/Core/Musify.Application/Contracts/IDatabase.cs index 5ec518a..89e0964 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabase.cs @@ -34,6 +34,8 @@ public interface IDatabase public Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); + public DbSet Set() where TEntity : class; + public Task SaveChangesAsync(CancellationToken cancellationToken); public Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/DeleteAlbumFromDbActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/DeleteAlbumFromDbActivity.cs deleted file mode 100644 index 37baf24..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/DeleteAlbumFromDbActivity.cs +++ /dev/null @@ -1,35 +0,0 @@ -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Infrastructure.MassTransit.Arguments; - -namespace Musify.Infrastructure.MassTransit.Activities.Albums; - -internal class DeleteAlbumFromDbActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity -{ - public const string ExecuteEndpointName = "delete-album-from-db"; - - public async Task Execute(ExecuteContext executeContext) - { - var album = await database.Albums - .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken); - - if (album == null) - { - logger.LogInformation( - "Album {AlbumId} not found while deleting from DB, skipping", - executeContext.Arguments.AlbumId); - return executeContext.Completed(); - } - - database.Albums.Remove(album); - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Deleted album {AlbumId} from DB", album.Id); - return executeContext.Completed(); - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs deleted file mode 100644 index c75652f..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs +++ /dev/null @@ -1,31 +0,0 @@ -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; -using Musify.Infrastructure.MassTransit.Arguments; - -namespace Musify.Infrastructure.MassTransit.Activities.Albums; - -internal class MarkAlbumAsFailedActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity -{ - public const string ExecuteEndpointName = "mark-album-as-failed"; - - public async Task Execute(ExecuteContext executeContext) - { - 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(); - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsRemovingActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsRemovingActivity.cs deleted file mode 100644 index edec538..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsRemovingActivity.cs +++ /dev/null @@ -1,36 +0,0 @@ -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; -using Musify.Infrastructure.MassTransit.Arguments; - -namespace Musify.Infrastructure.MassTransit.Activities.Albums; - -internal class MarkAlbumAsRemovingActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity -{ - public const string ExecuteEndpointName = "mark-album-as-removing"; - - public async Task Execute(ExecuteContext executeContext) - { - var album = await database.Albums - .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken); - - if (album == null) - { - logger.LogInformation( - "Album {AlbumId} not found while marking as removing, skipping", - executeContext.Arguments.AlbumId); - return executeContext.Completed(); - } - - album.LifeCycleStatus = LifeCycleStatus.Removing; - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Marked album {AlbumId} as removing", album.Id); - 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 edef0a9..9b38fb0 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs @@ -1,10 +1,9 @@ using MassTransit; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.RoutingSlip; namespace Musify.Infrastructure.MassTransit.Activities.Audio; @@ -17,26 +16,15 @@ internal class GenerateAudioWorkflowPathsActivity( public async Task Execute(ExecuteContext executeContext) { - 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; - } + var arguments = executeContext.Arguments; + var track = await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); 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); + + var workingDirectory = Path.Combine(arguments.TemporaryRootDirectory, Guid.NewGuid().ToString()); + var sourceFilePath = Path.Combine(workingDirectory, Path.GetFileName(arguments.SourceKey)); Directory.CreateDirectory(workingDirectory); await database.SaveChangesAsync(executeContext.CancellationToken); @@ -50,22 +38,9 @@ internal class GenerateAudioWorkflowPathsActivity( [RoutingSlipVariableNames.Audio.TranscodedDirectory] = workingDirectory }); } - catch (Exception exception) + catch { - 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); - } - + await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); throw; } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TrackAudioStatus.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TrackAudioStatus.cs new file mode 100644 index 0000000..7f40abc --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TrackAudioStatus.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Infrastructure.MassTransit.Activities.Audio; + +/// Shared bookkeeping of the audio processing activities. +internal static class TrackAudioStatus +{ + public static async Task LoadAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) => + await database.Tracks.SingleOrDefaultAsync(track => track.Id == trackId, cancellationToken) + ?? throw new InvalidOperationException($"Track with id {trackId} not found"); + + public static async Task MarkFailedAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) + { + var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); + if (track == null) + return; + + track.Audio.TranscodeStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(cancellationToken); + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs index 2c674b6..ee7e5d1 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs @@ -1,11 +1,9 @@ using MassTransit; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; +using Musify.Infrastructure.MassTransit.RoutingSlip; namespace Musify.Infrastructure.MassTransit.Activities.Audio; @@ -19,22 +17,13 @@ internal class TranscodeAudioActivity( public async Task Execute(ExecuteContext executeContext) { - var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.SourceFilePathVariable); - ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath)); - var workingDirectory = executeContext.GetVariable(executeContext.Arguments.WorkingDirectoryVariable); - ArgumentNullException.ThrowIfNull(workingDirectory, nameof(workingDirectory)); + var arguments = executeContext.Arguments; + var sourceFilePath = executeContext.GetVariable(arguments.SourceFilePathVariable); + ArgumentNullException.ThrowIfNull(sourceFilePath); + var workingDirectory = executeContext.GetVariable(arguments.WorkingDirectoryVariable); + ArgumentNullException.ThrowIfNull(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; - } + await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); try { @@ -42,9 +31,7 @@ internal class TranscodeAudioActivity( await using (var fileStream = File.OpenRead(sourceFilePath)) { transcodeResult = await audioTranscoder.TranscodeToAudioFileAsync( - fileStream, - workingDirectory, - executeContext.CancellationToken); + fileStream, workingDirectory, executeContext.CancellationToken); } if (transcodeResult.StatusCode != 0) @@ -52,51 +39,27 @@ internal class TranscodeAudioActivity( File.Delete(sourceFilePath); - var log = new TranscodeAudioLog(workingDirectory); - return executeContext.CompletedWithVariables(log, new Dictionary + return executeContext.CompletedWithVariables(new TranscodeAudioLog(workingDirectory), new Dictionary { [RoutingSlipVariableNames.Audio.DurationSeconds] = transcodeResult.Duration.TotalSeconds }); } - catch (Exception exception) + catch { - 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); - } - + await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); 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); - } + var workingDirectory = 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)); - } + if (Directory.Exists(workingDirectory)) + Directory.Delete(workingDirectory, recursive: true); + else + logger.LogWarning("Working directory {WorkingDirectory} not found during compensation", workingDirectory); + + return Task.FromResult(compensateContext.Compensated()); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs index ef79453..d683d09 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs @@ -1,9 +1,7 @@ using MassTransit; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Events; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; @@ -20,92 +18,49 @@ internal class UpdateTrackAudioActivity( public async Task Execute(ExecuteContext executeContext) { - 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)) - { - throw new InvalidOperationException($"Audio folder key is empty for track {track.Id}"); - } + var arguments = executeContext.Arguments; + var track = await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); try { - var durationSeconds = executeContext.GetVariable(executeContext.Arguments.DurationSecondsVariable) - ?? throw new InvalidOperationException($"Missing routing slip variable {executeContext.Arguments.DurationSecondsVariable}"); + if (string.IsNullOrWhiteSpace(arguments.AudioFolderKey)) + throw new InvalidOperationException($"Audio folder key is empty for track {track.Id}"); - var log = new UpdateTrackAudioLog( - track.Id, - track.Audio.FolderName, - track.DurationSeconds); + var durationSeconds = executeContext.GetVariable(arguments.DurationSecondsVariable) + ?? throw new InvalidOperationException($"Missing routing slip variable {arguments.DurationSecondsVariable}"); - track.Audio.FolderName = Path.GetFileName(audioFolderKey); + var log = new UpdateTrackAudioLog(track.Id, track.Audio.FolderName, track.DurationSeconds); + + track.Audio.FolderName = Path.GetFileName(arguments.AudioFolderKey); track.DurationSeconds = durationSeconds; track.Audio.TranscodeStatus = ProcessingStatus.Completed; await database.SaveChangesAsync(executeContext.CancellationToken); - logger.LogInformation("Updated track {TrackId} audio", executeContext.Arguments.TrackId); + logger.LogInformation("Updated track {TrackId} audio", arguments.TrackId); - await publishEndpoint.Publish( - new TrackAudioProcessed(executeContext.Arguments.TrackId), - executeContext.CancellationToken); + await publishEndpoint.Publish(new TrackAudioProcessed(arguments.TrackId), executeContext.CancellationToken); return executeContext.Completed(log); } - catch (Exception exception) + catch { - 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); - } - + await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); throw; } } 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"); + var log = compensateContext.Log; - track.Audio.FolderName = compensateContext.Log.PreviousAudioFolderName; - track.DurationSeconds = compensateContext.Log.PreviousDurationSeconds; - track.Audio.TranscodeStatus = ProcessingStatus.Failed; - await database.SaveChangesAsync(compensateContext.CancellationToken); + var track = await TrackAudioStatus.LoadAsync(database, log.TrackId, compensateContext.CancellationToken); + track.Audio.FolderName = log.PreviousAudioFolderName; + track.DurationSeconds = log.PreviousDurationSeconds; + track.Audio.TranscodeStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(compensateContext.CancellationToken); - logger.LogInformation("Compensated track {TrackId} audio", compensateContext.Log.TrackId); + logger.LogInformation("Compensated track {TrackId} audio", log.TrackId); - return compensateContext.Compensated(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to compensate track {TrackId} audio", - compensateContext.Log.TrackId); - return compensateContext.Failed(exception); - } + return compensateContext.Compensated(); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs index 727b65c..b6e4861 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs @@ -7,7 +7,7 @@ using Musify.Infrastructure.MassTransit.Logs; namespace Musify.Infrastructure.MassTransit.Activities.Files; internal class CopyFileInBucketActivity( - IStorageService storageHandler, + IStorageService storageService, ILogger logger) : IActivity { @@ -17,7 +17,7 @@ internal class CopyFileInBucketActivity( { try { - await storageHandler.CopyFileAsync( + await storageService.CopyFileAsync( executeContext.Arguments.SourceBucket, executeContext.Arguments.SourceKey, executeContext.Arguments.DestinationBucket, @@ -43,7 +43,7 @@ internal class CopyFileInBucketActivity( { try { - await storageHandler.RemoveFileAsync( + await storageService.RemoveFileAsync( compensateContext.Log.DestinationBucket, compensateContext.Log.DestinationKey, compensateContext.CancellationToken); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs index b59c525..6b246a5 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs @@ -7,7 +7,7 @@ using Musify.Infrastructure.MassTransit.Logs; namespace Musify.Infrastructure.MassTransit.Activities.Files; internal class DownloadFileFromBucketActivity( - IStorageService storageHandler, + IStorageService storageService, ILogger logger) : IActivity { @@ -16,11 +16,11 @@ internal class DownloadFileFromBucketActivity( public async Task Execute(ExecuteContext executeContext) { var destinationPath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); - ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath)); + ArgumentNullException.ThrowIfNull(destinationPath); try { - using var fileStream = await storageHandler.GetFileAsync( + using var fileStream = await storageService.GetFileAsync( executeContext.Arguments.Bucket, executeContext.Arguments.Key, executeContext.CancellationToken) diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs index f76808d..73c737b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs @@ -8,7 +8,7 @@ using Musify.Infrastructure.MassTransit.Arguments; namespace Musify.Infrastructure.MassTransit.Activities.Files; internal class RemoveFileFromBucketActivity( - IStorageService storageHandler, + IStorageService storageService, ILogger logger) : IExecuteActivity { @@ -18,7 +18,7 @@ internal class RemoveFileFromBucketActivity( { try { - await storageHandler.RemoveFileAsync( + await storageService.RemoveFileAsync( executeContext.Arguments.Bucket, executeContext.Arguments.Key, executeContext.CancellationToken); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs index fbf5e19..eb397d8 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs @@ -7,7 +7,7 @@ using Musify.Infrastructure.MassTransit.Logs; namespace Musify.Infrastructure.MassTransit.Activities.Files; internal class TransferFilesToBucketActivity( - IStorageService storageHandler, + IStorageService storageService, ILogger logger) : IActivity { @@ -16,13 +16,13 @@ internal class TransferFilesToBucketActivity( public async Task Execute(ExecuteContext executeContext) { var folderPath = executeContext.GetVariable(executeContext.Arguments.SourceDirectoryVariable); - ArgumentNullException.ThrowIfNull(folderPath, nameof(folderPath)); + ArgumentNullException.ThrowIfNull(folderPath); var destinationKey = executeContext.Arguments.DestinationKey; - ArgumentNullException.ThrowIfNull(destinationKey, nameof(destinationKey)); + ArgumentNullException.ThrowIfNull(destinationKey); try { - var uploadedKeys = await storageHandler.TransferFilesAsync( + var uploadedKeys = await storageService.TransferFilesAsync( folderPath, executeContext.Arguments.DestinationBucket, destinationKey, @@ -47,7 +47,7 @@ internal class TransferFilesToBucketActivity( { foreach (var uploadedKey in compensateContext.Log.UploadedKeys) { - await storageHandler.RemoveFileAsync( + await storageService.RemoveFileAsync( compensateContext.Log.DestinationBucket, uploadedKey, compensateContext.CancellationToken); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs index 3f8fed5..c9adc6f 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs @@ -6,7 +6,7 @@ using Musify.Infrastructure.MassTransit.Logs; namespace Musify.Infrastructure.MassTransit.Activities.Files; -internal class UploadFileToBucketActivity(IStorageService storageHandler, +internal class UploadFileToBucketActivity(IStorageService storageService, ILogger logger) : IActivity { @@ -15,7 +15,7 @@ internal class UploadFileToBucketActivity(IStorageService storageHandler, public async Task Execute(ExecuteContext executeContext) { var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.FilePathVariable); - ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath)); + ArgumentNullException.ThrowIfNull(sourceFilePath); var fileName = Path.GetFileName(sourceFilePath); var destinationKey = string.Join('/', new[] { executeContext.Arguments.DestinationRoute, fileName } .Where(static s => !string.IsNullOrWhiteSpace(s)) @@ -23,7 +23,7 @@ internal class UploadFileToBucketActivity(IStorageService storageHandler, try { - await storageHandler.UploadFileAsync( + await storageService.UploadFileAsync( sourceFilePath, executeContext.Arguments.DestinationBucket, destinationKey, @@ -43,7 +43,7 @@ internal class UploadFileToBucketActivity(IStorageService storageHandler, { try { - await storageHandler.RemoveFileAsync( + await storageService.RemoveFileAsync( compensateContext.Log.DestinationBucket, compensateContext.Log.DestinationKey, compensateContext.CancellationToken); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/LifeCycle/LifeCycleActivities.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/LifeCycle/LifeCycleActivities.cs new file mode 100644 index 0000000..a69d39e --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/LifeCycle/LifeCycleActivities.cs @@ -0,0 +1,97 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Domain.Abstractions; +using Musify.Domain.Entities; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities.LifeCycle; + +/// Sets the life-cycle status of an entity (removing, failed, ...). A missing entity is skipped. +internal abstract class MarkLifeCycleActivity(IDatabase database, ILogger logger) + : IExecuteActivity + where TEntity : class, IHasLifeCycle +{ + public async Task Execute(ExecuteContext executeContext) + { + var arguments = executeContext.Arguments; + var entity = await database.Set() + .SingleOrDefaultAsync(e => e.Id == arguments.Id, executeContext.CancellationToken); + + if (entity == null) + { + logger.LogInformation("{Entity} {Id} not found while marking it as {Status}, skipping", typeof(TEntity).Name, arguments.Id, arguments.Status); + return executeContext.Completed(); + } + + entity.LifeCycleStatus = arguments.Status; + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Marked {Entity} {Id} as {Status}", typeof(TEntity).Name, arguments.Id, arguments.Status); + return executeContext.Completed(); + } +} + +/// Deletes an entity row. A missing entity is skipped. +internal abstract class DeleteEntityActivity(IDatabase database, ILogger logger) + : IExecuteActivity + where TEntity : class, IHasLifeCycle +{ + public async Task Execute(ExecuteContext executeContext) + { + var id = executeContext.Arguments.Id; + var entity = await database.Set() + .SingleOrDefaultAsync(e => e.Id == id, executeContext.CancellationToken); + + if (entity == null) + { + logger.LogInformation("{Entity} {Id} not found while deleting it, skipping", typeof(TEntity).Name, id); + return executeContext.Completed(); + } + + database.Set().Remove(entity); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Deleted {Entity} {Id} from the database", typeof(TEntity).Name, id); + return executeContext.Completed(); + } +} + +// One concrete activity per entity keeps a separate queue for each of them: +// the endpoint name is the kebab-cased class name. +internal sealed class MarkTrackLifeCycleActivity(IDatabase database, ILogger logger) + : MarkLifeCycleActivity(database, logger) +{ + public const string ExecuteEndpointName = "mark-track-life-cycle"; +} + +internal sealed class MarkPlayListLifeCycleActivity(IDatabase database, ILogger logger) + : MarkLifeCycleActivity(database, logger) +{ + public const string ExecuteEndpointName = "mark-play-list-life-cycle"; +} + +internal sealed class MarkAlbumLifeCycleActivity(IDatabase database, ILogger logger) + : MarkLifeCycleActivity(database, logger) +{ + public const string ExecuteEndpointName = "mark-album-life-cycle"; +} + +internal sealed class DeleteTrackActivity(IDatabase database, ILogger logger) + : DeleteEntityActivity(database, logger) +{ + public const string ExecuteEndpointName = "delete-track"; +} + +internal sealed class DeletePlayListActivity(IDatabase database, ILogger logger) + : DeleteEntityActivity(database, logger) +{ + public const string ExecuteEndpointName = "delete-play-list"; +} + +internal sealed class DeleteAlbumActivity(IDatabase database, ILogger logger) + : DeleteEntityActivity(database, logger) +{ + public const string ExecuteEndpointName = "delete-album"; +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs index 37dfe63..48305af 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs @@ -1,6 +1,7 @@ using MassTransit; using Microsoft.Extensions.Logging; using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.RoutingSlip; namespace Musify.Infrastructure.MassTransit.Activities.Pictures; @@ -12,39 +13,27 @@ internal class GeneratePictureWorkflowPathsActivity( internal const string ResizedPictureFileExtension = ".webp"; - public async Task Execute(ExecuteContext executeContext) + public Task Execute(ExecuteContext executeContext) { - 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 Task.FromResult(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 + })); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs index dee6d73..f3614ba 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs @@ -16,9 +16,9 @@ internal class ResizePictureActivity( public async Task Execute(ExecuteContext executeContext) { var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.SourceFilePathVariable); - ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath)); + ArgumentNullException.ThrowIfNull(sourceFilePath); var destinationFilePath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); - ArgumentNullException.ThrowIfNull(destinationFilePath, nameof(destinationFilePath)); + ArgumentNullException.ThrowIfNull(destinationFilePath); try { diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs deleted file mode 100644 index 9b5d1c1..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs +++ /dev/null @@ -1,110 +0,0 @@ -using MassTransit; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Application.Events; -using Musify.Domain.ValueObjects; -using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Logs; - -namespace Musify.Infrastructure.MassTransit.Activities.Pictures; - -internal class UpdateAlbumPictureActivity( - IDatabase database, - IPublishEndpoint publishEndpoint, - ILogger logger) - : IActivity -{ - public const string ExecuteEndpointName = "update-album-picture"; - - public async Task Execute(ExecuteContext executeContext) - { - 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)); - - try - { - 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); - - 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(); - } - catch (Exception exception) - { - 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) - { - return compensateContext.Compensated(); - } - - album.Pictures = new EntityPictures - { - OriginalName = compensateContext.Log.PreviousOriginalPictureKey, - SmallName = compensateContext.Log.PreviousSmallPictureKey, - MediumName = compensateContext.Log.PreviousMediumPictureKey, - LargeName = compensateContext.Log.PreviousLargePictureKey - }; - - 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/UpdatePicturesActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePicturesActivity.cs new file mode 100644 index 0000000..76d84f3 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePicturesActivity.cs @@ -0,0 +1,160 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; + +namespace Musify.Infrastructure.MassTransit.Activities.Pictures; + +internal sealed record PictureNames(string? Original, string? Small, string? Medium, string? Large); + +/// Stores the processed picture names on the owner and reports it; compensation restores the previous names. +internal abstract class UpdatePicturesActivity( + IDatabase database, + IPublishEndpoint publishEndpoint, + ILogger logger) + : IActivity + where TEntity : class +{ + protected IDatabase Database { get; } = database; + + protected abstract Task FindAsync(Guid id, CancellationToken cancellationToken); + + /// Reads the current names; throws when the entity has no pending picture. + protected abstract PictureNames Read(TEntity entity); + + protected abstract void Write(TEntity entity, PictureNames names, bool succeeded); + + protected abstract object ProcessedEvent(Guid id); + + public async Task Execute(ExecuteContext executeContext) + { + var arguments = executeContext.Arguments; + var small = RequireVariable(executeContext, arguments.SmallPictureVariable); + var medium = RequireVariable(executeContext, arguments.MediumPictureVariable); + var large = RequireVariable(executeContext, arguments.LargePictureVariable); + + var entity = await FindAsync(arguments.SubjectId, executeContext.CancellationToken) + ?? throw new InvalidOperationException($"{typeof(TEntity).Name} {arguments.SubjectId} not found"); + + var previous = Read(entity); + Write( + entity, + new PictureNames( + Path.GetFileName(arguments.OriginalPictureKey), + Path.GetFileName(small), + Path.GetFileName(medium), + Path.GetFileName(large)), + succeeded: true); + await Database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Updated {Entity} {Id} pictures", typeof(TEntity).Name, arguments.SubjectId); + + await publishEndpoint.Publish(ProcessedEvent(arguments.SubjectId), executeContext.CancellationToken); + + return executeContext.Completed(new UpdatePicturesLog( + arguments.SubjectId, previous.Original, previous.Small, previous.Medium, previous.Large)); + } + + public async Task Compensate(CompensateContext compensateContext) + { + var log = compensateContext.Log; + + try + { + var entity = await FindAsync(log.SubjectId, compensateContext.CancellationToken); + if (entity == null) + return compensateContext.Compensated(); + + var previous = new PictureNames(log.PreviousOriginalName, log.PreviousSmallName, log.PreviousMediumName, log.PreviousLargeName); + Write(entity, previous, succeeded: false); + await Database.SaveChangesAsync(compensateContext.CancellationToken); + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate {Entity} {Id} pictures", typeof(TEntity).Name, log.SubjectId); + return compensateContext.Failed(exception); + } + } + + protected static PictureNames ReadPictures(EntityPictures? pictures) => + pictures == null + ? throw new InvalidOperationException("The entity has no pending picture upload") + : new PictureNames(pictures.OriginalName, pictures.SmallName, pictures.MediumName, pictures.LargeName); + + protected static void WritePictures(EntityPictures? pictures, PictureNames names) + { + if (pictures == null) + return; + + pictures.OriginalName = names.Original ?? pictures.OriginalName; + pictures.SmallName = names.Small; + pictures.MediumName = names.Medium; + pictures.LargeName = names.Large; + } + + private static string RequireVariable(ExecuteContext executeContext, string variableName) => + executeContext.GetVariable(variableName) + ?? throw new InvalidOperationException($"Missing routing slip variable {variableName}"); +} + +internal sealed class UpdateTrackPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) + : UpdatePicturesActivity(database, publishEndpoint, logger) +{ + public const string ExecuteEndpointName = "update-track-picture"; + + protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => + Database.Tracks.SingleOrDefaultAsync(track => track.Id == id, cancellationToken); + + protected override PictureNames Read(Track track) => + new(track.Pictures.OriginalName, track.Pictures.SmallName, track.Pictures.MediumName, track.Pictures.LargeName); + + protected override void Write(Track track, PictureNames names, bool succeeded) + { + track.Pictures.OriginalName = names.Original; + track.Pictures.SmallName = names.Small; + track.Pictures.MediumName = names.Medium; + track.Pictures.LargeName = names.Large; + track.Pictures.ProcessingStatus = succeeded ? ProcessingStatus.Completed : ProcessingStatus.Failed; + } + + protected override object ProcessedEvent(Guid id) => new TrackPictureProcessed(id); +} + +internal sealed class UpdatePlayListPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) + : UpdatePicturesActivity(database, publishEndpoint, logger) +{ + public const string ExecuteEndpointName = "update-play-list-picture"; + + protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => + Database.PlayLists.SingleOrDefaultAsync(playList => playList.Id == id, cancellationToken); + + protected override PictureNames Read(PlayList playList) => ReadPictures(playList.Pictures); + + protected override void Write(PlayList playList, PictureNames names, bool succeeded) => + WritePictures(playList.Pictures, names); + + protected override object ProcessedEvent(Guid id) => new PlayListPictureProcessed(id); +} + +internal sealed class UpdateAlbumPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) + : UpdatePicturesActivity(database, publishEndpoint, logger) +{ + public const string ExecuteEndpointName = "update-album-picture"; + + protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => + Database.Albums.SingleOrDefaultAsync(album => album.Id == id, cancellationToken); + + protected override PictureNames Read(Album album) => ReadPictures(album.Pictures); + + protected override void Write(Album album, PictureNames names, bool succeeded) => + WritePictures(album.Pictures, names); + + protected override object ProcessedEvent(Guid id) => new AlbumPictureProcessed(id); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs deleted file mode 100644 index d62995b..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs +++ /dev/null @@ -1,110 +0,0 @@ -using MassTransit; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Application.Events; -using Musify.Domain.ValueObjects; -using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Logs; - -namespace Musify.Infrastructure.MassTransit.Activities.Pictures; - -internal class UpdatePlayListPictureActivity( - IDatabase database, - IPublishEndpoint publishEndpoint, - ILogger logger) - : IActivity -{ - public const string ExecuteEndpointName = "update-play-list-picture"; - - public async Task Execute(ExecuteContext executeContext) - { - 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)); - - try - { - 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); - - 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(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to update playlist {PlayListId} pictures", - executeContext.Arguments.PlayListId); - throw; - } - } - - 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 EntityPictures - { - OriginalName = compensateContext.Log.PreviousOriginalPictureKey, - SmallName = compensateContext.Log.PreviousSmallPictureKey, - MediumName = compensateContext.Log.PreviousMediumPictureKey, - LargeName = compensateContext.Log.PreviousLargePictureKey - }; - - 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 deleted file mode 100644 index e209d28..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs +++ /dev/null @@ -1,100 +0,0 @@ -using MassTransit; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Application.Events; -using Musify.Domain.ValueObjects; -using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Logs; - -namespace Musify.Infrastructure.MassTransit.Activities.Pictures; - -internal class UpdateTrackPictureActivity( - IDatabase database, - IPublishEndpoint publishEndpoint, - ILogger logger) - : IActivity -{ - public const string ExecuteEndpointName = "update-track-picture"; - - public async Task Execute(ExecuteContext executeContext) - { - 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)); - - try - { - 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; - - 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(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to update track {TrackId} pictures", - executeContext.Arguments.TrackId); - throw; - } - } - - 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; - - 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 deleted file mode 100644 index 7ef578a..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs +++ /dev/null @@ -1,35 +0,0 @@ -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Infrastructure.MassTransit.Arguments; - -namespace Musify.Infrastructure.MassTransit.Activities.PlayLists; - -internal class DeletePlayListFromDbActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity -{ - public const string ExecuteEndpointName = "delete-play-list-from-db"; - - public async Task Execute(ExecuteContext executeContext) - { - 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); - 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 deleted file mode 100644 index fa15beb..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsFailedActivity.cs +++ /dev/null @@ -1,31 +0,0 @@ -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; -using Musify.Infrastructure.MassTransit.Arguments; - -namespace Musify.Infrastructure.MassTransit.Activities.PlayLists; - -internal class MarkPlayListAsFailedActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity -{ - public const string ExecuteEndpointName = "mark-play-list-as-failed"; - - public async Task Execute(ExecuteContext executeContext) - { - 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(); - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs deleted file mode 100644 index 52f0afb..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs +++ /dev/null @@ -1,36 +0,0 @@ -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; -using Musify.Infrastructure.MassTransit.Arguments; - -namespace Musify.Infrastructure.MassTransit.Activities.PlayLists; - -internal class MarkPlayListAsRemovingActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity -{ - public const string ExecuteEndpointName = "mark-play-list-as-removing"; - - public async Task Execute(ExecuteContext executeContext) - { - 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); - 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 deleted file mode 100644 index df3592e..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs +++ /dev/null @@ -1,35 +0,0 @@ -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Infrastructure.MassTransit.Arguments; - -namespace Musify.Infrastructure.MassTransit.Activities.Tracks; - -internal class DeleteTrackFromDbActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity -{ - public const string ExecuteEndpointName = "delete-track-from-db"; - - public async Task Execute(ExecuteContext executeContext) - { - 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); - 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 deleted file mode 100644 index 4d73742..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsFailedActivity.cs +++ /dev/null @@ -1,31 +0,0 @@ -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; -using Musify.Infrastructure.MassTransit.Arguments; - -namespace Musify.Infrastructure.MassTransit.Activities.Tracks; - -internal class MarkTrackAsFailedActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity -{ - public const string ExecuteEndpointName = "mark-track-as-failed"; - - public async Task Execute(ExecuteContext executeContext) - { - 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(); - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs deleted file mode 100644 index 1b22711..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs +++ /dev/null @@ -1,36 +0,0 @@ -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; -using Musify.Infrastructure.MassTransit.Arguments; - -namespace Musify.Infrastructure.MassTransit.Activities.Tracks; - -internal class MarkTrackAsRemovingActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity -{ - public const string ExecuteEndpointName = "mark-track-as-removing"; - - public async Task Execute(ExecuteContext executeContext) - { - 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); - return executeContext.Completed(); - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteAlbumFromDbArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteAlbumFromDbArguments.cs deleted file mode 100644 index ec2a2ca..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteAlbumFromDbArguments.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -internal record DeleteAlbumFromDbArguments( - Guid AlbumId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsFailedArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteEntityArguments.cs similarity index 50% rename from backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsFailedArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteEntityArguments.cs index ae3be70..b84ea8b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsFailedArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteEntityArguments.cs @@ -1,3 +1,3 @@ namespace Musify.Infrastructure.MassTransit.Arguments; -public record MarkTrackAsFailedArguments(Guid TrackId); +internal record DeleteEntityArguments(Guid Id); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs deleted file mode 100644 index d5b85f3..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index d3c9e6d..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteTrackFromDbArguments.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -internal record DeleteTrackFromDbArguments( - Guid TrackId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs deleted file mode 100644 index 36dd356..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record MarkAlbumAsFailedArguments(Guid AlbumId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsRemovingArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsRemovingArguments.cs deleted file mode 100644 index 65dc6d0..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsRemovingArguments.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -internal record MarkAlbumAsRemovingArguments( - Guid AlbumId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkLifeCycleArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkLifeCycleArguments.cs new file mode 100644 index 0000000..53bd438 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkLifeCycleArguments.cs @@ -0,0 +1,5 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Infrastructure.MassTransit.Arguments; + +internal record MarkLifeCycleArguments(Guid Id, LifeCycleStatus Status); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsFailedArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsFailedArguments.cs deleted file mode 100644 index e5e0436..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsFailedArguments.cs +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 5165864..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsRemovingArguments.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -internal record MarkPlayListAsRemovingArguments( - Guid PlayListId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs deleted file mode 100644 index 96c25a7..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -internal record MarkTrackAsRemovingArguments( - Guid TrackId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs deleted file mode 100644 index 6e9129c..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs +++ /dev/null @@ -1,8 +0,0 @@ -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/UpdateTrackPictureArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePicturesArguments.cs similarity index 75% rename from backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackPictureArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePicturesArguments.cs index 37a1ef4..18270ed 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackPictureArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePicturesArguments.cs @@ -1,7 +1,7 @@ namespace Musify.Infrastructure.MassTransit.Arguments; -public record UpdateTrackPictureArguments( - Guid TrackId, +public record UpdatePicturesArguments( + Guid SubjectId, string OriginalPictureKey, string SmallPictureVariable, string MediumPictureVariable, diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs deleted file mode 100644 index 7efcc0d..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs +++ /dev/null @@ -1,8 +0,0 @@ -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/Consumers/AlbumProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs index d076722..a8b46e2 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs @@ -1,37 +1,18 @@ using MassTransit; -using MassTransit.Courier.Contracts; using Musify.Application.Events; -using Musify.Infrastructure.MassTransit.Activities.Albums; -using Musify.Infrastructure.MassTransit.Activities.Files; -using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Activities.LifeCycle; using Musify.Infrastructure.MassTransit.RoutingSlip; namespace Musify.Infrastructure.MassTransit.Consumers; public class AlbumProcessingFailedConsumer(IBus bus) : IConsumer { - public async Task Consume(ConsumeContext context) + public 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)) - { - 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()); + return ProcessingFailedCleanup.ExecuteAsync( + bus, message.AlbumId, ActivityNames.MarkAlbumAsFailed, MarkAlbumLifeCycleActivity.ExecuteEndpointName, message.Bucket, + (ActivityNames.RemoveAlbumOriginalPicture, message.PictureKey)); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs index 74c2dce..1b6e0c5 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs @@ -4,17 +4,9 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers; -public class CreateAlbumConsumer( - IBus bus, - AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder) +public class CreateAlbumConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) : IConsumer { - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs index 008dd63..012df87 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs @@ -4,17 +4,9 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers; -public class CreatePlayListConsumer( - IBus bus, - PlayListPictureSourceRoutingSlipBuilder routingSlipBuilder) +public class CreatePlayListConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) : IConsumer { - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs index 0bdd8d2..9a7cb5e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs @@ -4,17 +4,9 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers; -public class CreateTrackConsumer( - IBus bus, - CreateTrackRoutingSlipBuilder routingSlipBuilder) +public class CreateTrackConsumer(IBus bus, CreateTrackRoutingSlipBuilder routingSlipBuilder) : IConsumer { - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs index cf9b4fc..4802a43 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs @@ -4,21 +4,15 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers; -public class DeleteAlbumConsumer( - IBus bus, - DeleteAlbumRoutingSlipBuilder routingSlipBuilder) +public class DeleteAlbumConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) : IConsumer { - public async Task Consume(ConsumeContext consumeContext) + public async Task Consume(ConsumeContext context) { - var routingSlip = (await routingSlipBuilder - .BuildAsync( - consumeContext.Message.AlbumId, - consumeContext.Message.UserId, - consumeContext.CorrelationId, - consumeContext.CancellationToken)) - .Build(); + var message = context.Message; + var routingSlip = await routingSlipBuilder.BuildAlbumAsync( + message.AlbumId, message.UserId, context.CorrelationId, context.CancellationToken); - await bus.Execute(routingSlip); + await bus.Execute(routingSlip.Build()); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs index fea8f01..8175eba 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs @@ -4,21 +4,15 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers; -public class DeletePlayListConsumer( - IBus bus, - DeletePlayListRoutingSlipBuilder routingSlipBuilder) +public class DeletePlayListConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) : IConsumer { - public async Task Consume(ConsumeContext consumeContext) + public async Task Consume(ConsumeContext context) { - var routingSlip = (await routingSlipBuilder - .BuildAsync( - consumeContext.Message.PlayListId, - consumeContext.Message.UserId, - consumeContext.CorrelationId, - consumeContext.CancellationToken)) - .Build(); + var message = context.Message; + var routingSlip = await routingSlipBuilder.BuildPlayListAsync( + message.PlayListId, message.UserId, context.CorrelationId, context.CancellationToken); - await bus.Execute(routingSlip); + await bus.Execute(routingSlip.Build()); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs index 9d3aa28..4d2b123 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs @@ -4,17 +4,15 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers; -public class DeleteTrackConsumer( - IBus bus, - DeleteTrackRoutingSlipBuilder routingSlipBuilder) +public class DeleteTrackConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) : IConsumer { - public async Task Consume(ConsumeContext consumeContext) + public async Task Consume(ConsumeContext context) { - var routingSlip = (await routingSlipBuilder - .BuildAsync(consumeContext.Message.TrackId, consumeContext.Message.UserId, consumeContext.CorrelationId, consumeContext.CancellationToken)) - .Build(); + var message = context.Message; + var routingSlip = await routingSlipBuilder.BuildTrackAsync( + message.TrackId, message.UserId, context.CorrelationId, context.CancellationToken); - await bus.Execute(routingSlip); + await bus.Execute(routingSlip.Build()); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/EndpointHelper.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/EndpointHelper.cs deleted file mode 100644 index 945661b..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/EndpointHelper.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Consumers; - -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 c06ccf4..78bcb62 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs @@ -1,37 +1,18 @@ using MassTransit; -using MassTransit.Courier.Contracts; using Musify.Application.Events; -using Musify.Infrastructure.MassTransit.Activities.Files; -using Musify.Infrastructure.MassTransit.Activities.PlayLists; -using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Activities.LifeCycle; using Musify.Infrastructure.MassTransit.RoutingSlip; namespace Musify.Infrastructure.MassTransit.Consumers; public class PlayListProcessingFailedConsumer(IBus bus) : IConsumer { - public async Task Consume(ConsumeContext context) + public 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)) - { - 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()); + return ProcessingFailedCleanup.ExecuteAsync( + bus, message.PlayListId, ActivityNames.MarkPlayListAsFailed, MarkPlayListLifeCycleActivity.ExecuteEndpointName, message.Bucket, + (ActivityNames.RemovePlayListOriginalPicture, message.PictureKey)); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingFailedCleanup.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingFailedCleanup.cs new file mode 100644 index 0000000..434ad9b --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingFailedCleanup.cs @@ -0,0 +1,34 @@ +using MassTransit; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.RoutingSlip; + +namespace Musify.Infrastructure.MassTransit.Consumers; + +internal static class ProcessingFailedCleanup +{ + /// Removes whatever files were left behind and marks the entity as failed. + public static Task ExecuteAsync( + IBus bus, + Guid subjectId, + string markStepName, + string markEndpointName, + string? bucket, + params (string StepName, string? Key)[] files) + { + var builder = RoutingSlips.Create(correlationId: null); + + if (!string.IsNullOrWhiteSpace(bucket)) + { + foreach (var (stepName, key) in files) + { + if (!string.IsNullOrWhiteSpace(key)) + builder.AddRemoveFile(stepName, bucket, key); + } + } + + builder.AddStep(markStepName, markEndpointName, new MarkLifeCycleArguments(subjectId, LifeCycleStatus.Failed)); + + return bus.Execute(builder.Build()); + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs index 8c1dca6..c41dd54 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs @@ -1,6 +1,7 @@ using MassTransit; using MassTransit.Courier.Contracts; using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip; namespace Musify.Infrastructure.MassTransit.Consumers; diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs index d04ca6a..870e010 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs @@ -1,6 +1,7 @@ using MassTransit; using MassTransit.Courier.Contracts; using Microsoft.Extensions.Logging; +using Musify.Infrastructure.MassTransit.RoutingSlip; namespace Musify.Infrastructure.MassTransit.Consumers; @@ -32,14 +33,9 @@ public class RoutingSlipCleanUpConsumer( Directory.Delete(pathString, recursive: true); logger.LogInformation("Deleted temporal directory at path {Path}", pathString); } - catch (DirectoryNotFoundException directoryNotFoundException) + catch (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; + logger.LogWarning("Temporal directory at path {Path} was not found for deletion", pathString); } return Task.CompletedTask; diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs index 6a0e645..c2f5a3b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs @@ -1,45 +1,19 @@ using MassTransit; -using MassTransit.Courier.Contracts; using Musify.Application.Events; -using Musify.Infrastructure.MassTransit.Activities.Files; -using Musify.Infrastructure.MassTransit.Activities.Tracks; -using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Activities.LifeCycle; using Musify.Infrastructure.MassTransit.RoutingSlip; namespace Musify.Infrastructure.MassTransit.Consumers; public class TrackProcessingFailedConsumer(IBus bus) : IConsumer { - public async Task Consume(ConsumeContext context) + public 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)) - { - 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()); + return ProcessingFailedCleanup.ExecuteAsync( + bus, message.TrackId, ActivityNames.MarkTrackAsFailed, MarkTrackLifeCycleActivity.ExecuteEndpointName, message.Bucket, + (ActivityNames.RemoveTrackOriginalPicture, message.PictureKey), + (ActivityNames.RemoveTrackOriginalAudio, message.AudioKey)); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs index 9e28c7d..ad9f8f3 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs @@ -4,17 +4,9 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers; -public class UpdateAlbumPictureConsumer( - IBus bus, - PictureWorkflowRoutingSlipBuilder routingSlipBuilder) +public class UpdateAlbumPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) : IConsumer { - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs index 928dd5a..4069246 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs @@ -4,17 +4,9 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers; -public class UpdateAlbumPictureSourceConsumer( - IBus bus, - AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder) +public class UpdateAlbumPictureSourceConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) : IConsumer { - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs index ba52e76..d89df5e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs @@ -4,17 +4,9 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers; -public class UpdatePlayListPictureConsumer( - IBus bus, - PictureWorkflowRoutingSlipBuilder routingSlipBuilder) +public class UpdatePlayListPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) : IConsumer { - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs index ea0605b..8082d79 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs @@ -4,17 +4,9 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers; -public class UpdatePlayListPictureSourceConsumer( - IBus bus, - PlayListPictureSourceRoutingSlipBuilder routingSlipBuilder) +public class UpdatePlayListPictureSourceConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) : IConsumer { - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs index 828aba8..221dc24 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs @@ -4,17 +4,9 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers; -public class UpdateTrackAudioConsumer( - IBus bus, - AudioWorkflowRoutingSlipBuilder routingSlipBuilder) +public class UpdateTrackAudioConsumer(IBus bus, AudioWorkflowRoutingSlipBuilder routingSlipBuilder) : IConsumer { - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs index 9f4b1e6..d26de5a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs @@ -4,17 +4,9 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers; -public class UpdateTrackPictureConsumer( - IBus bus, - PictureWorkflowRoutingSlipBuilder routingSlipBuilder) +public class UpdateTrackPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) : IConsumer { - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs index f86a6cc..b039d5a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs @@ -11,6 +11,7 @@ using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Consumers; using Musify.Infrastructure.MassTransit.Logs; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; +using Musify.Infrastructure.MassTransit.Activities.LifeCycle; namespace Musify.Infrastructure.MassTransit; @@ -19,61 +20,55 @@ public static partial class MassTransitDependencyInjection private 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(); } private static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options) { - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); - options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); options.AddExecuteActivity(); options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); + options.AddActivity(); + options.AddActivity(); options.AddActivity(); + options.AddActivity(); options.AddActivity(); options.AddActivity(); + options.AddActivity(); + options.AddActivity(); options.AddActivity(); + options.AddActivity(); options.AddActivity(); - options.AddActivity(); - options.AddActivity(); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs index e125e39..8ed3d83 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs @@ -18,14 +18,7 @@ public static partial class MassTransitDependencyInjection services.AddScoped(); services.AddMassTransit(options => { - options.AddEntityFrameworkOutbox(outbox => - { - outbox.UsePostgres(); - outbox.UseBusOutbox(); - - outbox.QueryDelay = TimeSpan.FromSeconds(1); - outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); - }); + AddOutbox(options); options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => ConfigureRabbitMqHost( @@ -46,42 +39,20 @@ public static partial class MassTransitDependencyInjection services.AddMassTransit(options => { - options.AddEntityFrameworkOutbox(outbox => - { - outbox.UsePostgres(); - outbox.UseBusOutbox(); - - outbox.QueryDelay = TimeSpan.FromSeconds(1); - outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); - }); + AddOutbox(options); options.SetKebabCaseEndpointNameFormatter(); RegisterConsumersAndActivities(options); options.AddSagaStateMachine() - .EntityFrameworkRepository(repository => - { - repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; - repository.ExistingDbContext(); - repository.UsePostgres(); - }); + .EntityFrameworkRepository(ConfigureSagaRepository); options.AddSagaStateMachine() - .EntityFrameworkRepository(repository => - { - repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; - repository.ExistingDbContext(); - repository.UsePostgres(); - }); + .EntityFrameworkRepository(ConfigureSagaRepository); options.AddSagaStateMachine() - .EntityFrameworkRepository(repository => - { - repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; - repository.ExistingDbContext(); - repository.UsePostgres(); - }); + .EntityFrameworkRepository(ConfigureSagaRepository); options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) => { @@ -102,6 +73,24 @@ public static partial class MassTransitDependencyInjection return services; } + private static void AddOutbox(IBusRegistrationConfigurator options) => + options.AddEntityFrameworkOutbox(outbox => + { + outbox.UsePostgres(); + outbox.UseBusOutbox(); + + outbox.QueryDelay = TimeSpan.FromSeconds(1); + outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); + }); + + private static void ConfigureSagaRepository(IEntityFrameworkSagaRepositoryConfigurator repository) + where TSaga : class, ISaga + { + repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; + repository.ExistingDbContext(); + repository.UsePostgres(); + } + private static void UseStandardRetry(IReceiveEndpointConfigurator endpointConfigurator) => endpointConfigurator.UseMessageRetry(retryConfigurator => retryConfigurator.Exponential( diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs deleted file mode 100644 index d14f075..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs +++ /dev/null @@ -1,8 +0,0 @@ -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/UpdatePicturesLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePicturesLog.cs new file mode 100644 index 0000000..d17297d --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePicturesLog.cs @@ -0,0 +1,8 @@ +namespace Musify.Infrastructure.MassTransit.Logs; + +public record UpdatePicturesLog( + Guid SubjectId, + string? PreviousOriginalName, + string? PreviousSmallName, + string? PreviousMediumName, + string? PreviousLargeName); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs deleted file mode 100644 index 8ee81b5..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs +++ /dev/null @@ -1,8 +0,0 @@ -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/UpdateTrackPictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs deleted file mode 100644 index 316b827..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs +++ /dev/null @@ -1,8 +0,0 @@ -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/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs deleted file mode 100644 index 5beae29..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs +++ /dev/null @@ -1,87 +0,0 @@ -using MassTransit; -using MassTransit.Courier.Contracts; -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; - -public class AlbumPictureSourceRoutingSlipBuilder -{ - public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) - => BuildSlip( - message.AlbumId, - message.PictureIntentId, - message.Bucket, - message.PictureSourceKey, - message.PictureDestinationKey, - message.Sizes, - correlationId, - RoutingSlipVariableNames.ProcessKinds.AlbumCreation); - - public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId) - => BuildSlip( - message.AlbumId, - message.PictureIntentId, - message.Bucket, - message.PictureSourceKey, - message.PictureDestinationKey, - message.Sizes, - correlationId, - RoutingSlipVariableNames.ProcessKinds.AlbumPicture); - - private static RoutingSlipBuilder BuildSlip( - Guid albumId, - Guid pictureIntentId, - string bucket, - string pictureSourceKey, - string pictureDestinationKey, - ImageSizes sizes, - Guid? correlationId, - string processKind) - { - 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.CopyPictureToFinal, - EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), - new CopyFileInBucketArguments( - bucket, - pictureSourceKey, - bucket, - pictureDestinationKey)); - - routingSlipBuilder.AddActivity( - ActivityNames.ConsumeUploadIntents, - EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName), - new ConsumeUploadIntentsArguments([pictureIntentId])); - - routingSlipBuilder.AddActivity( - ActivityNames.PublishAlbumPictureProcessingEvent, - EndpointHelper.BuildExecuteActivityUri(PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName), - new PublishAlbumPictureProcessingEventArguments( - albumId, - bucket, - pictureDestinationKey, - sizes)); - - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, albumId); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, bucket); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, pictureDestinationKey); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), - RoutingSlipEvents.Faulted); - - 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 c8745b8..b3f1e07 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs @@ -1,71 +1,44 @@ using MassTransit; -using MassTransit.Courier.Contracts; using Musify.Application.Events; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.MassTransit.Activities.Audio; using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Consumers; namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; public class AudioWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) { - public RoutingSlipBuilder Build(UpdateTrackAudioEvent message, 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.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.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.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); - - return routingSlipBuilder; - } + public RoutingSlipBuilder Build(UpdateTrackAudioEvent message, Guid? correlationId) => + RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.GenerateAudioWorkflowPaths, + GenerateAudioWorkflowPathsActivity.ExecuteEndpointName, + new GenerateAudioWorkflowPathsArguments( + message.TrackId, workerConfiguration.Routes.TemporaryFilesDirectory, message.SourceKey)) + .AddStep( + ActivityNames.DownloadFile, + DownloadFileFromBucketActivity.ExecuteEndpointName, + new DownloadFileFromBucketArguments( + message.SourceBucket, message.SourceKey, RoutingSlipVariableNames.Audio.SourceFilePath)) + .AddStep( + ActivityNames.TranscodeAudio, + TranscodeAudioActivity.ExecuteEndpointName, + new TranscodeAudioArguments( + message.TrackId, + RoutingSlipVariableNames.Audio.SourceFilePath, + RoutingSlipVariableNames.Workflow.TemporalDirectory)) + .AddStep( + ActivityNames.TransferFiles, + TransferFilesToBucketActivity.ExecuteEndpointName, + new TransferFilesToBucketArguments( + message.DestinationBucket, + RoutingSlipVariableNames.Audio.TranscodedDirectory, + message.DestinationFolderKey)) + .AddStep( + ActivityNames.UpdateTrackAudio, + UpdateTrackAudioActivity.ExecuteEndpointName, + new UpdateTrackAudioArguments( + message.TrackId, message.DestinationFolderKey, RoutingSlipVariableNames.Audio.DurationSeconds)) + .TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackAudio); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs index f50a844..d8b1f50 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs @@ -1,11 +1,9 @@ using MassTransit; -using MassTransit.Courier.Contracts; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Activities.Tracks; using Musify.Infrastructure.MassTransit.Activities.UploadIntents; using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Consumers; namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; @@ -13,57 +11,35 @@ public class CreateTrackRoutingSlipBuilder { public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId) { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.CopyPictureToFinal, + CopyFileInBucketActivity.ExecuteEndpointName, + new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey, message.Bucket, message.PictureDestinationKey)) + .AddStep( + ActivityNames.CopyAudioToFinal, + CopyFileInBucketActivity.ExecuteEndpointName, + new CopyFileInBucketArguments(message.Bucket, message.AudioSourceKey, message.Bucket, message.AudioDestinationKey)) + .AddStep( + ActivityNames.ConsumeUploadIntents, + ConsumeUploadIntentsActivity.ExecuteEndpointName, + new ConsumeUploadIntentsArguments([message.PictureIntentId, message.AudioIntentId])) + .AddStep( + ActivityNames.PublishTrackProcessingEvents, + PublishTrackProcessingEventsActivity.ExecuteEndpointName, + new PublishTrackProcessingEventsArguments( + message.TrackId, + message.Bucket, + message.PictureDestinationKey, + message.AudioDestinationKey, + message.AudioProcessedFolderKey, + message.Sizes)) + .TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackCreation); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, message.Bucket); + builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey); + builder.AddVariable(RoutingSlipVariableNames.Workflow.AudioKey, message.AudioDestinationKey); - 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.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.Sizes)); - - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackCreation); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, message.Bucket); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.AudioKey, message.AudioDestinationKey); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), - RoutingSlipEvents.Faulted); - - return routingSlipBuilder; + return builder; } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteAlbumRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteAlbumRoutingSlipBuilder.cs deleted file mode 100644 index 95011c6..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteAlbumRoutingSlipBuilder.cs +++ /dev/null @@ -1,93 +0,0 @@ -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.Albums; -using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Consumers; - -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; - -public class DeleteAlbumRoutingSlipBuilder( - IDatabase database, - AlbumConfiguration albumConfiguration, - ApplicationStorageConfiguration storageConfiguration) -{ - public async Task BuildAsync( - Guid albumId, - 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 album = await database.Albums - .AsNoTracking() - .SingleOrDefaultAsync(p => p.Id == albumId, cancellationToken); - - routingSlipBuilder.AddActivity( - ActivityNames.MarkAlbumAsRemoving, - EndpointHelper.BuildExecuteActivityUri(MarkAlbumAsRemovingActivity.ExecuteEndpointName), - new MarkAlbumAsRemovingArguments(albumId)); - - if (album?.Pictures != null) - { - var bucket = storageConfiguration.Bucket; - var routes = albumConfiguration.Routes; - - if (album.Pictures.OriginalName is { } originalName) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemoveAlbumOriginalPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - routes.BuildOriginalPicturePath(userId, originalName))); - } - - if (album.Pictures.SmallName is { } smallName) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemoveAlbumSmallPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - routes.BuildPicturePath(PictureSize.Small, smallName))); - } - - if (album.Pictures.MediumName is { } mediumName) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemoveAlbumMediumPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - routes.BuildPicturePath(PictureSize.Medium, mediumName))); - } - - if (album.Pictures.LargeName is { } largeName) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemoveAlbumLargePicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - routes.BuildPicturePath(PictureSize.Large, largeName))); - } - } - - routingSlipBuilder.AddActivity( - ActivityNames.DeleteAlbumFromDb, - EndpointHelper.BuildExecuteActivityUri(DeleteAlbumFromDbActivity.ExecuteEndpointName), - new DeleteAlbumFromDbArguments(albumId)); - - return routingSlipBuilder; - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs deleted file mode 100644 index c05a4f0..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs +++ /dev/null @@ -1,93 +0,0 @@ -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; - -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; - -public class DeletePlayListRoutingSlipBuilder( - IDatabase database, - PlayListConfiguration playListConfiguration, - ApplicationStorageConfiguration storageConfiguration) -{ - 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 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.BuildPicturePath(PictureSize.Small, smallName))); - } - - if (playList.Pictures.MediumName is { } mediumName) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemovePlayListMediumPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - routes.BuildPicturePath(PictureSize.Medium, mediumName))); - } - - if (playList.Pictures.LargeName is { } largeName) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemovePlayListLargePicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - routes.BuildPicturePath(PictureSize.Large, largeName))); - } - } - - routingSlipBuilder.AddActivity( - ActivityNames.DeletePlayListFromDb, - EndpointHelper.BuildExecuteActivityUri(DeletePlayListFromDbActivity.ExecuteEndpointName), - new DeletePlayListFromDbArguments(playListId)); - - return routingSlipBuilder; - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs new file mode 100644 index 0000000..b13d252 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs @@ -0,0 +1,135 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Activities.LifeCycle; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +/// Marks an entity as removing, deletes its files from the bucket and finally deletes its row. +public class DeleteRoutingSlipBuilder( + IDatabase database, + TrackConfiguration trackConfiguration, + AlbumConfiguration albumConfiguration, + PlayListConfiguration playListConfiguration, + ApplicationStorageConfiguration storageConfiguration) +{ + private sealed record PictureStepNames(string Original, string Small, string Medium, string Large); + + private static readonly PictureStepNames AlbumSteps = new( + ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.RemoveAlbumSmallPicture, + ActivityNames.RemoveAlbumMediumPicture, ActivityNames.RemoveAlbumLargePicture); + + private static readonly PictureStepNames PlayListSteps = new( + ActivityNames.RemovePlayListOriginalPicture, ActivityNames.RemovePlayListSmallPicture, + ActivityNames.RemovePlayListMediumPicture, ActivityNames.RemovePlayListLargePicture); + + public async Task BuildTrackAsync( + Guid trackId, long userId, Guid? correlationId, CancellationToken cancellationToken) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.MarkTrackAsRemoving, + MarkTrackLifeCycleActivity.ExecuteEndpointName, + new MarkLifeCycleArguments(trackId, LifeCycleStatus.Removing)); + + var track = await database.Tracks.AsNoTracking().SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); + if (track != null) + { + var bucket = storageConfiguration.Bucket; + var routes = trackConfiguration.Routes; + + if (!string.IsNullOrEmpty(track.Pictures.OriginalName)) + { + builder.AddRemoveFile( + ActivityNames.RemoveTrackOriginalPicture, bucket, + routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName)); + } + + // Unprocessed tracks still point at the shared preset pictures, which must never be deleted. + if (track.Pictures.IsProcessed) + { + builder.AddRemoveFile(ActivityNames.RemoveTrackSmallPicture, bucket, routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName)); + builder.AddRemoveFile(ActivityNames.RemoveTrackMediumPicture, bucket, routes.BuildPicturePath(PictureSize.Medium, track.Pictures.MediumName)); + builder.AddRemoveFile(ActivityNames.RemoveTrackLargePicture, bucket, routes.BuildPicturePath(PictureSize.Large, track.Pictures.LargeName)); + } + + if (!string.IsNullOrEmpty(track.Audio.OriginalName)) + { + builder.AddRemoveFile( + ActivityNames.RemoveTrackOriginalAudio, bucket, + routes.BuildOriginalAudioPath(userId, track.Audio.OriginalName)); + } + + if (track.Audio.IsProcessed) + { + builder.AddRemoveFile( + ActivityNames.RemoveTrackProcessedAudio, bucket, + routes.BuildProcessedAudioPath(track.Audio.FolderName)); + } + } + + return builder.AddStep( + ActivityNames.DeleteTrackFromDb, DeleteTrackActivity.ExecuteEndpointName, new DeleteEntityArguments(trackId)); + } + + public async Task BuildAlbumAsync( + Guid albumId, long userId, Guid? correlationId, CancellationToken cancellationToken) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.MarkAlbumAsRemoving, + MarkAlbumLifeCycleActivity.ExecuteEndpointName, + new MarkLifeCycleArguments(albumId, LifeCycleStatus.Removing)); + + var pictures = await database.Albums.AsNoTracking() + .Where(a => a.Id == albumId) + .Select(a => a.Pictures) + .SingleOrDefaultAsync(cancellationToken); + + AddPictureRemovals(builder, AlbumSteps, albumConfiguration.Routes, userId, pictures); + + return builder.AddStep( + ActivityNames.DeleteAlbumFromDb, DeleteAlbumActivity.ExecuteEndpointName, new DeleteEntityArguments(albumId)); + } + + public async Task BuildPlayListAsync( + Guid playListId, long userId, Guid? correlationId, CancellationToken cancellationToken) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.MarkPlayListAsRemoving, + MarkPlayListLifeCycleActivity.ExecuteEndpointName, + new MarkLifeCycleArguments(playListId, LifeCycleStatus.Removing)); + + var pictures = await database.PlayLists.AsNoTracking() + .Where(p => p.Id == playListId) + .Select(p => p.Pictures) + .SingleOrDefaultAsync(cancellationToken); + + AddPictureRemovals(builder, PlayListSteps, playListConfiguration.Routes, userId, pictures); + + return builder.AddStep( + ActivityNames.DeletePlayListFromDb, DeletePlayListActivity.ExecuteEndpointName, new DeleteEntityArguments(playListId)); + } + + private void AddPictureRemovals( + RoutingSlipBuilder builder, PictureStepNames steps, PictureRoutes routes, long userId, EntityPictures? pictures) + { + if (pictures == null) + return; + + var bucket = storageConfiguration.Bucket; + + if (pictures.OriginalName is { } original) + builder.AddRemoveFile(steps.Original, bucket, routes.BuildOriginalPicturePath(userId, original)); + if (pictures.SmallName is { } small) + builder.AddRemoveFile(steps.Small, bucket, routes.BuildPicturePath(PictureSize.Small, small)); + if (pictures.MediumName is { } medium) + builder.AddRemoveFile(steps.Medium, bucket, routes.BuildPicturePath(PictureSize.Medium, medium)); + if (pictures.LargeName is { } large) + builder.AddRemoveFile(steps.Large, bucket, routes.BuildPicturePath(PictureSize.Large, large)); + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs deleted file mode 100644 index bdd7062..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs +++ /dev/null @@ -1,106 +0,0 @@ -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; - -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; - -public class DeleteTrackRoutingSlipBuilder( - IDatabase database, - TrackConfiguration trackConfiguration, - ApplicationStorageConfiguration storageConfiguration) -{ - 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 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.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName))); - - routingSlipBuilder.AddActivity( - ActivityNames.RemoveTrackMediumPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - trackConfiguration.Routes.BuildPicturePath(PictureSize.Medium, track.Pictures.MediumName))); - - routingSlipBuilder.AddActivity( - ActivityNames.RemoveTrackLargePicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - trackConfiguration.Routes.BuildPicturePath(PictureSize.Large, 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/PictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureSourceRoutingSlipBuilder.cs new file mode 100644 index 0000000..2069f14 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureSourceRoutingSlipBuilder.cs @@ -0,0 +1,74 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.Activities.Albums; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.PlayLists; +using Musify.Infrastructure.MassTransit.Activities.UploadIntents; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +/// +/// Moves an uploaded album/playlist picture to its final key, consumes the upload intent and hands over to the picture workflow. +/// Creation faults mark the whole entity as failed; update faults only report the picture as failed. +/// +public class PictureSourceRoutingSlipBuilder +{ + public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) => + BuildSlip( + message.AlbumId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, + correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumCreation, + ActivityNames.PublishAlbumPictureProcessingEvent, PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName, + new PublishAlbumPictureProcessingEventArguments(message.AlbumId, message.Bucket, message.PictureDestinationKey, message.Sizes)); + + public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId) => + BuildSlip( + message.AlbumId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, + correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumPicture, + ActivityNames.PublishAlbumPictureProcessingEvent, PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName, + new PublishAlbumPictureProcessingEventArguments(message.AlbumId, message.Bucket, message.PictureDestinationKey, message.Sizes)); + + public RoutingSlipBuilder Build(CreatePlayListResourcesEvent message, Guid? correlationId) => + BuildSlip( + message.PlayListId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, + correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListCreation, + ActivityNames.PublishPlayListPictureProcessingEvent, PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName, + new PublishPlayListPictureProcessingEventArguments(message.PlayListId, message.Bucket, message.PictureDestinationKey, message.Sizes)); + + public RoutingSlipBuilder Build(UpdatePlayListPictureSourceEvent message, Guid? correlationId) => + BuildSlip( + message.PlayListId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, + correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, + ActivityNames.PublishPlayListPictureProcessingEvent, PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName, + new PublishPlayListPictureProcessingEventArguments(message.PlayListId, message.Bucket, message.PictureDestinationKey, message.Sizes)); + + private static RoutingSlipBuilder BuildSlip( + Guid subjectId, + Guid pictureIntentId, + string bucket, + string pictureSourceKey, + string pictureDestinationKey, + Guid? correlationId, + string processKind, + string publishStepName, + string publishEndpointName, + object publishArguments) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.CopyPictureToFinal, + CopyFileInBucketActivity.ExecuteEndpointName, + new CopyFileInBucketArguments(bucket, pictureSourceKey, bucket, pictureDestinationKey)) + .AddStep( + ActivityNames.ConsumeUploadIntents, + ConsumeUploadIntentsActivity.ExecuteEndpointName, + new ConsumeUploadIntentsArguments([pictureIntentId])) + .AddStep(publishStepName, publishEndpointName, publishArguments) + .TrackFaults(subjectId, processKind); + + builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, bucket); + builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, pictureDestinationKey); + + return builder; + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs index ba6fa48..bf81fe6 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs @@ -1,176 +1,86 @@ using MassTransit; -using MassTransit.Courier.Contracts; using Musify.Application.Events; using Musify.Application.Shared; using Musify.Infrastructure.Configuration; 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; +/// Downloads a picture, resizes it to the three sizes, uploads them and records the result on the owner. public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) { - public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) - { - var routingSlipBuilder = BuildPictureWorkflow( - message.Bucket, - message.SourceKey, - message.Sizes, - correlationId); + public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) => + BuildPictureWorkflow( + message.TrackId, message.Bucket, message.SourceKey, message.Sizes, correlationId, + ActivityNames.UpdateTrackPicture, UpdateTrackPictureActivity.ExecuteEndpointName, + RoutingSlipVariableNames.ProcessKinds.TrackPicture); - routingSlipBuilder.AddActivity( - ActivityNames.UpdateTrackPicture, - EndpointHelper.BuildExecuteActivityUri(UpdateTrackPictureActivity.ExecuteEndpointName), - new UpdateTrackPictureArguments( - message.TrackId, - message.SourceKey, - RoutingSlipVariableNames.Picture.SmallResizedFilePath, - RoutingSlipVariableNames.Picture.MediumResizedFilePath, - RoutingSlipVariableNames.Picture.LargeResizedFilePath)); + public RoutingSlipBuilder Build(UpdatePlayListPictureEvent message, Guid? correlationId) => + BuildPictureWorkflow( + message.PlayListId, message.Bucket, message.SourceKey, message.Sizes, correlationId, + ActivityNames.UpdatePlayListPicture, UpdatePlayListPictureActivity.ExecuteEndpointName, + RoutingSlipVariableNames.ProcessKinds.PlayListPicture); - 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.Sizes, - 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.Sizes, - 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; - } + public RoutingSlipBuilder Build(UpdateAlbumPictureEvent message, Guid? correlationId) => + BuildPictureWorkflow( + message.AlbumId, message.Bucket, message.SourceKey, message.Sizes, correlationId, + ActivityNames.UpdateAlbumPicture, UpdateAlbumPictureActivity.ExecuteEndpointName, + RoutingSlipVariableNames.ProcessKinds.AlbumPicture); private RoutingSlipBuilder BuildPictureWorkflow( + Guid subjectId, string bucket, string sourceKey, ImageSizes sizes, - Guid? correlationId) + Guid? correlationId, + string updateStepName, + string updateEndpointName, + string processKind) { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.GeneratePictureWorkflowPaths, + GeneratePictureWorkflowPathsActivity.ExecuteEndpointName, + new GeneratePictureWorkflowPathsArguments(workerConfiguration.Routes.TemporaryFilesDirectory, sourceKey)) + .AddStep( + ActivityNames.DownloadFile, + DownloadFileFromBucketActivity.ExecuteEndpointName, + new DownloadFileFromBucketArguments(bucket, sourceKey, RoutingSlipVariableNames.Picture.OriginalFilePath)); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + AddResize(builder, ActivityNames.ResizeSmall, ActivityNames.UploadSmall, bucket, RoutingSlipVariableNames.Picture.SmallResizedFilePath, sizes.Small); + AddResize(builder, ActivityNames.ResizeMedium, ActivityNames.UploadMedium, bucket, RoutingSlipVariableNames.Picture.MediumResizedFilePath, sizes.Medium); + AddResize(builder, ActivityNames.ResizeLarge, ActivityNames.UploadLarge, bucket, RoutingSlipVariableNames.Picture.LargeResizedFilePath, sizes.Large); - 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, - sizes.Small); - - AddResizeActivity( - routingSlipBuilder, - ActivityNames.ResizeMedium, - ActivityNames.UploadMedium, - bucket, - RoutingSlipVariableNames.Picture.MediumResizedFilePath, - sizes.Medium); - - AddResizeActivity( - routingSlipBuilder, - ActivityNames.ResizeLarge, - ActivityNames.UploadLarge, - bucket, - RoutingSlipVariableNames.Picture.LargeResizedFilePath, - sizes.Large); - - return routingSlipBuilder; + return builder + .AddStep( + updateStepName, + updateEndpointName, + new UpdatePicturesArguments( + subjectId, + sourceKey, + RoutingSlipVariableNames.Picture.SmallResizedFilePath, + RoutingSlipVariableNames.Picture.MediumResizedFilePath, + RoutingSlipVariableNames.Picture.LargeResizedFilePath)) + .TrackFaults(subjectId, processKind); } - 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)); - } + private static void AddResize( + RoutingSlipBuilder builder, + string resizeStepName, + string uploadStepName, + string bucket, + string resizedFileVariable, + ImageSize size) => + builder + .AddStep( + resizeStepName, + ResizePictureActivity.ExecuteEndpointName, + new ResizePictureLocalArguments( + RoutingSlipVariableNames.Picture.OriginalFilePath, resizedFileVariable, size.Width, size.Height)) + .AddStep( + uploadStepName, + UploadFileToBucketActivity.ExecuteEndpointName, + new UploadFileToBucketArguments(resizedFileVariable, bucket, size.SavePath)); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs deleted file mode 100644 index 6744749..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs +++ /dev/null @@ -1,87 +0,0 @@ -using MassTransit; -using MassTransit.Courier.Contracts; -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; - -public class PlayListPictureSourceRoutingSlipBuilder -{ - public RoutingSlipBuilder Build(CreatePlayListResourcesEvent message, Guid? correlationId) - => BuildSlip( - message.PlayListId, - message.PictureIntentId, - message.Bucket, - message.PictureSourceKey, - message.PictureDestinationKey, - message.Sizes, - correlationId, - RoutingSlipVariableNames.ProcessKinds.PlayListCreation); - - public RoutingSlipBuilder Build(UpdatePlayListPictureSourceEvent message, Guid? correlationId) - => BuildSlip( - message.PlayListId, - message.PictureIntentId, - message.Bucket, - message.PictureSourceKey, - message.PictureDestinationKey, - message.Sizes, - correlationId, - RoutingSlipVariableNames.ProcessKinds.PlayListPicture); - - private static RoutingSlipBuilder BuildSlip( - Guid playListId, - Guid pictureIntentId, - string bucket, - string pictureSourceKey, - string pictureDestinationKey, - ImageSizes sizes, - Guid? correlationId, - string processKind) - { - 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.CopyPictureToFinal, - EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), - new CopyFileInBucketArguments( - bucket, - pictureSourceKey, - bucket, - pictureDestinationKey)); - - routingSlipBuilder.AddActivity( - ActivityNames.ConsumeUploadIntents, - EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName), - new ConsumeUploadIntentsArguments([pictureIntentId])); - - routingSlipBuilder.AddActivity( - ActivityNames.PublishPlayListPictureProcessingEvent, - EndpointHelper.BuildExecuteActivityUri(PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName), - new PublishPlayListPictureProcessingEventArguments( - playListId, - bucket, - pictureDestinationKey, - sizes)); - - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, playListId); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, bucket); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, pictureDestinationKey); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), - RoutingSlipEvents.Faulted); - - return routingSlipBuilder; - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs index 6d49e2e..393aea4 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs @@ -1,4 +1,4 @@ -namespace Musify.Infrastructure.MassTransit; +namespace Musify.Infrastructure.MassTransit.RoutingSlip; internal static class RoutingSlipVariableNames { diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs new file mode 100644 index 0000000..7134165 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs @@ -0,0 +1,48 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Consumers; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip; + +/// Shared building blocks for every routing slip in the worker. +internal static class RoutingSlips +{ + public static Uri ExecuteUri(string endpointName) => new($"queue:{endpointName}_execute"); + + public static Uri ConsumerUri(string queueName) => new($"queue:{queueName}"); + + /// A new slip that carries the correlation id and always cleans its temporary directory up. + public static RoutingSlipBuilder Create(Guid? correlationId) + { + var builder = new RoutingSlipBuilder(NewId.NextGuid()); + builder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + builder.AddSubscription( + ConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + return builder; + } + + public static RoutingSlipBuilder AddStep( + this RoutingSlipBuilder builder, string name, string executeEndpointName, object arguments) + { + builder.AddActivity(name, ExecuteUri(executeEndpointName), arguments); + return builder; + } + + /// Publishes a failure event for the subject when the slip faults. + public static RoutingSlipBuilder TrackFaults( + this RoutingSlipBuilder builder, Guid subjectId, string processKind) + { + builder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, subjectId); + builder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind); + builder.AddSubscription(ConsumerUri(ProcessingSlipFaultConsumer.QueueName), RoutingSlipEvents.Faulted); + return builder; + } + + public static RoutingSlipBuilder AddRemoveFile( + this RoutingSlipBuilder builder, string stepName, string bucket, string key) => + builder.AddStep(stepName, RemoveFileFromBucketActivity.ExecuteEndpointName, new RemoveFileFromBucketArguments(bucket, key)); +} From fcf3093ad81801aae30cc2df5d7cee67a5dc0d2a Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 12:36:48 +0200 Subject: [PATCH 13/21] Unify API route parameters, return IResult everywhere, merge picture upload DTOs and centralize validation limits Guid and long route constraints, id/trackId parameter names, Update*Request without the New prefix, sentence-case operation summaries, shared security scheme id and a single Limits class. --- .../Authentication/JwtBearerOptionsSetup.cs | 14 +- .../RequestAlbumPictureUploadRequest.cs | 6 - .../Albums/UpdateAlbumRequest.cs | 8 +- .../PictureUploadRequest.cs | 6 + .../RequestPlayListPictureUploadRequest.cs | 6 - .../PlayLists/UpdatePlayListRequest.cs | 8 +- .../Musify.Api/Endpoints/AlbumEndpoints.cs | 62 +- .../Musify.Api/Endpoints/ConfigEndpoints.cs | 7 +- .../Musify.Api/Endpoints/GenreEndpoints.cs | 14 +- .../Musify.Api/Endpoints/LikeEndpoints.cs | 8 +- .../Musify.Api/Endpoints/MixEndpoints.cs | 6 +- .../Musify.Api/Endpoints/PlayListEndpoints.cs | 58 +- .../Musify.Api/Endpoints/TrackEndpoints.cs | 36 +- .../Musify.Api/Endpoints/UserEndpoints.cs | 59 +- .../Musify.Api/OpenApi/OpenApiOptionsSetup.cs | 24 +- backend/Hosts/Musify.Api/Program.cs | 11 +- .../Musify.Api/Scalar/ScalarOptionsSetup.cs | 23 +- .../Validators/Albums/AlbumReleaseYear.cs | 8 - .../Albums/CreateAlbumRequestValidator.cs | 6 +- .../Albums/UpdateAlbumRequestValidator.cs | 14 +- backend/Hosts/Musify.Api/Validators/Limits.cs | 12 + ...or.cs => PictureUploadRequestValidator.cs} | 8 +- .../CreatePlayListRequestValidator.cs | 4 +- ...stPlayListPictureUploadRequestValidator.cs | 20 - .../UpdatePlayListRequestValidator.cs | 16 +- .../Tracks/CreateTrackRequestValidator.cs | 2 +- .../Endpoints/AlbumEndpointsTests.cs | 3 +- .../CreateAlbumRequestValidatorTests.cs | 9 +- .../Validators/UploadRequestValidatorTests.cs | 12 +- web-player/src/lib/api/schema.d.ts | 1028 ++++++++--------- web-player/src/lib/server/playlistActions.ts | 4 +- .../routes/(app)/albums/[id]/+page.server.ts | 24 +- .../src/routes/(app)/library/+page.server.ts | 4 +- .../routes/(app)/playlists/+page.server.ts | 4 +- .../(app)/playlists/[id]/+page.server.ts | 23 +- .../routes/(app)/user/[id]/+page.server.ts | 4 +- web-player/src/routes/+page.server.ts | 4 +- .../routes/api/albums/[id]/tracks/+server.ts | 4 +- 38 files changed, 740 insertions(+), 829 deletions(-) delete mode 100644 backend/Hosts/Musify.Api/DataTransferObjects/Albums/RequestAlbumPictureUploadRequest.cs create mode 100644 backend/Hosts/Musify.Api/DataTransferObjects/PictureUploadRequest.cs delete mode 100644 backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/RequestPlayListPictureUploadRequest.cs delete mode 100644 backend/Hosts/Musify.Api/Validators/Albums/AlbumReleaseYear.cs create mode 100644 backend/Hosts/Musify.Api/Validators/Limits.cs rename backend/Hosts/Musify.Api/Validators/{Albums/RequestAlbumPictureUploadRequestValidator.cs => PictureUploadRequestValidator.cs} (55%) delete mode 100644 backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs diff --git a/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs b/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs index db86913..880fd13 100644 --- a/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs @@ -8,27 +8,25 @@ namespace Musify.Api.Authentication; public sealed class JwtBearerOptionsSetup(IOptions options) : IConfigureNamedOptions { - private readonly AuthenticationConfiguration _configuration = options.Value; + private readonly AuthenticationConfiguration configuration = options.Value; public void Configure(string? name, JwtBearerOptions options) { if (name != JwtBearerDefaults.AuthenticationScheme) - { return; - } Configure(options); } public void Configure(JwtBearerOptions options) { - options.MetadataAddress = _configuration.MetadataAddress; - options.RequireHttpsMetadata = _configuration.RequireHttpsMetadata; - options.Audience = _configuration.AudienceAddress; + options.MetadataAddress = configuration.MetadataAddress; + options.RequireHttpsMetadata = configuration.RequireHttpsMetadata; + options.Audience = configuration.AudienceAddress; options.TokenValidationParameters = new TokenValidationParameters { - ValidIssuer = _configuration.IssuerAddress, - ValidAudience = _configuration.AudienceAddress, + ValidIssuer = configuration.IssuerAddress, + ValidAudience = configuration.AudienceAddress, RoleClaimType = ClaimTypes.Role, NameClaimType = ClaimTypes.Name, }; diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/RequestAlbumPictureUploadRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/RequestAlbumPictureUploadRequest.cs deleted file mode 100644 index a1fb913..0000000 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/RequestAlbumPictureUploadRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Musify.Api.DataTransferObjects.Albums; - -public record RequestAlbumPictureUploadRequest( - string FileType, - string ContentType, - long? ExpectedSizeBytes = null); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs index d01812e..a814831 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs @@ -1,7 +1,7 @@ namespace Musify.Api.DataTransferObjects.Albums; public record UpdateAlbumRequest( - string NewTitle, - string? NewDescription, - int? NewReleaseYear, - Guid? NewPictureIntentId); + string Title, + string? Description, + int? ReleaseYear, + Guid? PictureIntentId); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PictureUploadRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PictureUploadRequest.cs new file mode 100644 index 0000000..c41becc --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PictureUploadRequest.cs @@ -0,0 +1,6 @@ +namespace Musify.Api.DataTransferObjects; + +public record PictureUploadRequest( + string FileType, + string ContentType, + long? ExpectedSizeBytes = null); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/RequestPlayListPictureUploadRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/RequestPlayListPictureUploadRequest.cs deleted file mode 100644 index a72e25c..0000000 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/RequestPlayListPictureUploadRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Musify.Api.DataTransferObjects.PlayLists; - -public record RequestPlayListPictureUploadRequest( - string FileType, - string ContentType, - long? ExpectedSizeBytes = null); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs index ddf8b87..78a4d11 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs @@ -3,7 +3,7 @@ using Musify.Domain.ValueObjects; namespace Musify.Api.DataTransferObjects.PlayLists; public record UpdatePlayListRequest( - string? NewName, - string? NewDescription, - Guid? NewPictureIntentId, - PlayListVisibility? NewVisibility = null); + string? Name, + string? Description, + Guid? PictureIntentId, + PlayListVisibility? Visibility = null); diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index 481e0d4..1e56399 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -4,12 +4,12 @@ using Musify.Api.DataTransferObjects; using Musify.Api.DataTransferObjects.Albums; using Musify.Api.Extensions; using Musify.Api.Filters; +using Musify.Api.Models; using Musify.Application.Albums; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using Musify.Application.Pictures; using Musify.Application.Pictures.Responses; -using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; @@ -27,46 +27,46 @@ public static class AlbumEndpoints group.MapGet("/", GetAlbums) .AddEndpointFilter>() .WithName("GetAlbums") - .WithSummary("Get Paginated Albums, Optionally Filtered By Title.") + .WithSummary("Get paginated albums, optionally filtered by title") .Produces>(); - group.MapGet("/{id}", GetAlbumById) + group.MapGet("/{id:guid}", GetAlbumById) .WithName("GetAlbumById") - .WithSummary("Get An Album By Id.") + .WithSummary("Get an album by id") .Produces() .Produces(StatusCodes.Status404NotFound); - group.MapGet("/users/{userId}", GetAlbumsByUserId) + group.MapGet("/users/{userId:long}", GetAlbumsByUserId) .AddEndpointFilter>() .WithName("GetAlbumsByUserId") - .WithSummary("Get Paginated Albums For A User.") + .WithSummary("Get paginated albums for a user") .Produces>() .Produces(StatusCodes.Status404NotFound); group.MapGet("/recent", GetRecentlyListenedAlbums) .WithName("GetRecentlyListenedAlbums") - .WithSummary("Get The Albums The Current User Listened To Most Recently.") + .WithSummary("Get the albums the current user listened to most recently") .RequireAuthorization() .Produces>() .Produces(StatusCodes.Status401Unauthorized); - group.MapGet("/{albumId}/tracks", GetAlbumTracks) + group.MapGet("/{id:guid}/tracks", GetAlbumTracks) .AddEndpointFilter>() .WithName("GetAlbumTracks") - .WithSummary("Get The Tracks Of An Album Ordered By Track Number.") + .WithSummary("Get the tracks of an album ordered by track number") .Produces>() .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id}/cover", GetAlbumCover) + group.MapGet("/{id:guid}/cover", GetAlbumCover) .WithName("GetAlbumCover") - .WithSummary("Get An Album Cover Image.") + .WithSummary("Get an album cover image") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); group.MapPost("/upload-picture", RequestAlbumPictureUpload) .WithName("RequestAlbumPictureUpload") - .WithSummary("Request A Pre-Signed URL To Upload An Album Picture.") - .AddEndpointFilter>() + .WithSummary("Request a pre-signed URL to upload an album picture") + .AddEndpointFilter>() .RequireAuthorization() .Produces() .ProducesValidationProblem() @@ -75,7 +75,7 @@ public static class AlbumEndpoints group.MapPost("/", CreateAlbum) .WithName("CreateAlbum") - .WithSummary("Create A New Album.") + .WithSummary("Create a new album") .AddEndpointFilter>() .RequireAuthorization() .Produces(StatusCodes.Status201Created) @@ -83,9 +83,9 @@ public static class AlbumEndpoints .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); - group.MapPut("/{albumId}", UpdateAlbum) + group.MapPut("/{id:guid}", UpdateAlbum) .WithName("UpdateAlbum") - .WithSummary("Update An Album.") + .WithSummary("Update an album") .AddEndpointFilter>() .RequireAuthorization() .Produces() @@ -94,18 +94,18 @@ public static class AlbumEndpoints .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); - group.MapDelete("/{albumId}", DeleteAlbum) + group.MapDelete("/{id:guid}", DeleteAlbum) .WithName("DeleteAlbum") - .WithSummary("Delete An Album.") + .WithSummary("Delete an album") .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); - group.MapPost("/{id}/tracks", AddAlbumTracks) + group.MapPost("/{id:guid}/tracks", AddAlbumTracks) .WithName("AddAlbumTracks") - .WithSummary("Add Your Uploaded Tracks To An Album, Skipping The Ones It Already Has.") + .WithSummary("Add your uploaded tracks to an album, skipping the ones it already has") .AddEndpointFilter>() .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) @@ -115,9 +115,9 @@ public static class AlbumEndpoints .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); - group.MapDelete("/{albumId}/tracks/{trackId}", RemoveTrackFromAlbum) + group.MapDelete("/{id:guid}/tracks/{trackId:guid}", RemoveTrackFromAlbum) .WithName("RemoveTrackFromAlbum") - .WithSummary("Remove A Track From An Album.") + .WithSummary("Remove a track from an album") .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) @@ -176,11 +176,11 @@ public static class AlbumEndpoints private static async Task GetAlbumTracks( IMediator mediator, - Guid albumId, + Guid id, CancellationToken cancellationToken, [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetAlbumTracksQuery(albumId, page.PageNumber, page.PageSize), cancellationToken); + var result = await mediator.Send(new GetAlbumTracksQuery(id, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); } @@ -198,7 +198,7 @@ public static class AlbumEndpoints private static async Task RequestAlbumPictureUpload( IMediator mediator, CurrentUser currentUser, - RequestAlbumPictureUploadRequest request, + PictureUploadRequest request, CancellationToken cancellationToken) { var result = await mediator.Send( @@ -224,12 +224,12 @@ public static class AlbumEndpoints private static async Task UpdateAlbum( IMediator mediator, CurrentUser currentUser, - Guid albumId, + Guid id, UpdateAlbumRequest request, CancellationToken cancellationToken) { var result = await mediator.Send( - new UpdateAlbumCommand(currentUser.RequiredId, albumId, request.NewTitle, request.NewDescription, request.NewReleaseYear, request.NewPictureIntentId), + new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), cancellationToken); return result.ToHttpResult(); @@ -238,10 +238,10 @@ public static class AlbumEndpoints private static async Task DeleteAlbum( IMediator mediator, CurrentUser currentUser, - Guid albumId, + Guid id, CancellationToken cancellationToken) { - var result = await mediator.Send(new DeleteAlbumCommand(currentUser.RequiredId, albumId), cancellationToken); + var result = await mediator.Send(new DeleteAlbumCommand(currentUser.RequiredId, id), cancellationToken); return result.ToHttpResult(); } @@ -262,12 +262,12 @@ public static class AlbumEndpoints private static async Task RemoveTrackFromAlbum( IMediator mediator, CurrentUser currentUser, - Guid albumId, + Guid id, Guid trackId, CancellationToken cancellationToken) { var result = await mediator.Send( - new RemoveTrackFromAlbumCommand(currentUser.RequiredId, albumId, trackId), + new RemoveTrackFromAlbumCommand(currentUser.RequiredId, id, trackId), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs index ed4220a..517e79c 100644 --- a/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs @@ -13,16 +13,17 @@ public static class ConfigEndpoints group.MapGet("/playback", GetPlaybackPublicConfig) .WithName("GetPlaybackPublicConfig") - .WithSummary("Get The Public Playback Configuration (Whether Anonymous Listening Is Allowed). Works Anonymously.") + .WithSummary("Get the public playback configuration (whether anonymous listening is allowed). Works anonymously") .Produces(); return app; } - private static async Task GetPlaybackPublicConfig( + private static async Task GetPlaybackPublicConfig( IMediator mediator, CancellationToken cancellationToken) { - return await mediator.Send(new GetPlaybackPublicConfigQuery(), cancellationToken); + var result = await mediator.Send(new GetPlaybackPublicConfigQuery(), cancellationToken); + return Results.Ok(result); } } diff --git a/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs index 382655b..d779c88 100644 --- a/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs @@ -13,28 +13,30 @@ public static class GenreEndpoints group.MapGet("/", GetGenres) .WithName("GetGenres") - .WithSummary("Get The Genres That Have At Least One Track, Most Populated First. Works Anonymously.") + .WithSummary("Get the genres that have at least one track, most populated first. Works anonymously") .Produces>(); group.MapGet("/available", GetAvailableGenres) .WithName("GetAvailableGenres") - .WithSummary("Get Every Genre A Track Can Be Tagged With, Including The Genres It Cannot Be Combined With. Works Anonymously.") + .WithSummary("Get every genre a track can be tagged with, including the genres it cannot be combined with. Works anonymously") .Produces>(); return app; } - private static async Task> GetAvailableGenres( + private static async Task GetAvailableGenres( IMediator mediator, CancellationToken cancellationToken) { - return await mediator.Send(new GetAvailableGenresQuery(), cancellationToken); + var result = await mediator.Send(new GetAvailableGenresQuery(), cancellationToken); + return Results.Ok(result); } - private static async Task> GetGenres( + private static async Task GetGenres( IMediator mediator, CancellationToken cancellationToken) { - return await mediator.Send(new GetGenresQuery(), cancellationToken); + var result = await mediator.Send(new GetGenresQuery(), cancellationToken); + return Results.Ok(result); } } diff --git a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs index 22f747e..6d1ec73 100644 --- a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs @@ -2,11 +2,11 @@ using Mediator; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.Likes; using Musify.Api.Extensions; -using Musify.Application.Likes; +using Musify.Api.Filters; using Musify.Api.Models; +using Musify.Application.Likes; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using Musify.Api.Filters; namespace Musify.Api.Endpoints; @@ -21,13 +21,13 @@ public static class LikeEndpoints group.MapGet("/", GetLikedTracks) .AddEndpointFilter>() .WithName("GetLikedTracks") - .WithSummary("Get The Current User'S Liked Tracks.") + .WithSummary("Get the current user's liked tracks") .Produces>() .Produces(StatusCodes.Status401Unauthorized); group.MapPost("/toggle", ToggleTrackLike) .WithName("ToggleTrackLike") - .WithSummary("Like Or Unlike A Track By Id.") + .WithSummary("Like or unlike a track by id") .Produces() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); diff --git a/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs index 371cbde..b0b0736 100644 --- a/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs @@ -16,13 +16,13 @@ public static class MixEndpoints group.MapGet("/", GetMixes) .WithName("GetMixes") - .WithSummary("Get The Server-Generated Mixes Of The Current User.") + .WithSummary("Get the server-generated mixes of the current user") .Produces>() .Produces(StatusCodes.Status401Unauthorized); - group.MapGet("/{id}", GetMixById) + group.MapGet("/{id:guid}", GetMixById) .WithName("GetMixById") - .WithSummary("Get A Mix With All Of Its Songs.") + .WithSummary("Get a mix with all of its songs") .Produces() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index c25e71d..74d51ac 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -4,12 +4,12 @@ using Musify.Api.DataTransferObjects; using Musify.Api.DataTransferObjects.PlayLists; using Musify.Api.Extensions; using Musify.Api.Filters; +using Musify.Api.Models; using Musify.Application.Contracts; using Musify.Application.Pictures; using Musify.Application.Pictures.Responses; using Musify.Application.PlayLists; using Musify.Application.PlayLists.Responses; -using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; @@ -22,34 +22,34 @@ public static class PlayListEndpoints var group = app.MapGroup("/playlists") .WithTags("PlayLists"); - group.MapGet("/{id}", GetPlayListById) + group.MapGet("/{id:guid}", GetPlayListById) .WithName("GetPlayListById") - .WithSummary("Get A PlayList By Id.") + .WithSummary("Get a playlist by id") .Produces() .Produces(StatusCodes.Status404NotFound); - group.MapGet("/users/{userId}", GetPlayListsByUserId) + group.MapGet("/users/{userId:long}", GetPlayListsByUserId) .AddEndpointFilter>() .WithName("GetPlayListsByUserId") - .WithSummary("Get Paginated PlayLists For A User. Private PlayLists Are Only Returned To Their Owner.") + .WithSummary("Get paginated playlists for a user. Private playlists are only returned to their owner") .Produces>(); - group.MapGet("/{playlistId}/tracks", GetPlayListTracks) + group.MapGet("/{id:guid}/tracks", GetPlayListTracks) .AddEndpointFilter>() .WithName("GetPlayListTracks") - .WithSummary("Get Paginated Tracks Of A PlayList.") + .WithSummary("Get paginated tracks of a playlist") .Produces>() .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id}/cover", GetPlayListCover) + group.MapGet("/{id:guid}/cover", GetPlayListCover) .WithName("GetPlayListCover") - .WithSummary("Get A PlayList Cover Image.") + .WithSummary("Get a playlist cover image") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); group.MapPost("/", CreatePlayList) .WithName("CreatePlayList") - .WithSummary("Create A New PlayList.") + .WithSummary("Create a new playlist") .AddEndpointFilter>() .RequireAuthorization() .Produces(StatusCodes.Status201Created) @@ -57,9 +57,9 @@ public static class PlayListEndpoints .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); - group.MapPut("/{playlistId}", UpdatePlayList) + group.MapPut("/{id:guid}", UpdatePlayList) .WithName("UpdatePlayList") - .WithSummary("Update An Existing PlayList.") + .WithSummary("Update an existing playlist") .AddEndpointFilter>() .RequireAuthorization() .Produces() @@ -68,9 +68,9 @@ public static class PlayListEndpoints .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); - group.MapDelete("/{playlistId}", DeletePlayList) + group.MapDelete("/{id:guid}", DeletePlayList) .WithName("DeletePlayList") - .WithSummary("Delete A PlayList.") + .WithSummary("Delete a playlist") .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) @@ -79,17 +79,17 @@ public static class PlayListEndpoints group.MapPost("/upload-picture", RequestPlayListPictureUpload) .WithName("RequestPlayListPictureUpload") - .WithSummary("Request A Pre-Signed URL To Upload A PlayList Picture.") - .AddEndpointFilter>() + .WithSummary("Request a pre-signed URL to upload a playlist picture") + .AddEndpointFilter>() .RequireAuthorization() .Produces() .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); - group.MapPost("/{id}/tracks", AddPlayListTracks) + group.MapPost("/{id:guid}/tracks", AddPlayListTracks) .WithName("AddPlayListTracks") - .WithSummary("Add Tracks To A PlayList, Skipping The Ones It Already Has.") + .WithSummary("Add tracks to a playlist, skipping the ones it already has") .AddEndpointFilter>() .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) @@ -99,9 +99,9 @@ public static class PlayListEndpoints .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); - group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList) + group.MapDelete("/{id:guid}/tracks/{trackId:guid}", RemoveTrackFromPlayList) .WithName("RemoveTrackFromPlayList") - .WithSummary("Remove A Track From A PlayList.") + .WithSummary("Remove a track from a playlist") .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) @@ -136,11 +136,11 @@ public static class PlayListEndpoints private static async Task GetPlayListTracks( IMediator mediator, CurrentUser currentUser, - Guid playlistId, + Guid id, CancellationToken cancellationToken, [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); + var result = await mediator.Send(new GetPlayListTracksQuery(id, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); return result.ToHttpResult(); } @@ -171,12 +171,12 @@ public static class PlayListEndpoints private static async Task UpdatePlayList( IMediator mediator, CurrentUser currentUser, - Guid playlistId, + Guid id, UpdatePlayListRequest request, CancellationToken cancellationToken) { var result = await mediator.Send( - new UpdatePlayListCommand(currentUser.RequiredId, playlistId, request.NewName, request.NewDescription, request.NewPictureIntentId, request.NewVisibility), + new UpdatePlayListCommand(currentUser.RequiredId, id, request.Name, request.Description, request.PictureIntentId, request.Visibility), cancellationToken); return result.ToHttpResult(); @@ -185,17 +185,17 @@ public static class PlayListEndpoints private static async Task DeletePlayList( IMediator mediator, CurrentUser currentUser, - Guid playlistId, + Guid id, CancellationToken cancellationToken) { - var result = await mediator.Send(new DeletePlayListCommand(currentUser.RequiredId, playlistId), cancellationToken); + var result = await mediator.Send(new DeletePlayListCommand(currentUser.RequiredId, id), cancellationToken); return result.ToHttpResult(); } private static async Task RequestPlayListPictureUpload( IMediator mediator, CurrentUser currentUser, - RequestPlayListPictureUploadRequest request, + PictureUploadRequest request, CancellationToken cancellationToken) { var result = await mediator.Send( @@ -222,12 +222,12 @@ public static class PlayListEndpoints private static async Task RemoveTrackFromPlayList( IMediator mediator, CurrentUser currentUser, - Guid playlistId, + Guid id, Guid trackId, CancellationToken cancellationToken) { var result = await mediator.Send( - new RemoveTrackFromPlayListCommand(currentUser.RequiredId, playlistId, trackId), + new RemoveTrackFromPlayListCommand(currentUser.RequiredId, id, trackId), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index 1a1fd88..3db1c4e 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -3,9 +3,9 @@ using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.Extensions; using Musify.Api.Filters; +using Musify.Api.Models; using Musify.Application.Contracts; using Musify.Application.Pictures; -using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; @@ -22,47 +22,47 @@ public static class TrackEndpoints group.MapGet("/", GetTracks) .AddEndpointFilter>() .WithName("GetTracks") - .WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.") + .WithSummary("Get paginated tracks, optionally filtered by name and genre") .Produces>() .ProducesValidationProblem(); - group.MapGet("/{id}", GetTrackById) + group.MapGet("/{id:guid}", GetTrackById) .WithName("GetTrackById") - .WithSummary("Get A Track By Id.") + .WithSummary("Get a track by id") .Produces() .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id}/cover", GetTrackCover) + group.MapGet("/{id:guid}/cover", GetTrackCover) .WithName("GetTrackCover") - .WithSummary("Get A Track Cover Image.") + .WithSummary("Get a track cover image") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id}/stream", GetTrackStream) + group.MapGet("/{id:guid}/stream", GetTrackStream) .WithName("GetTrackStream") - .WithSummary("Get A Streaming Manifest URL And Ticket For A Track. Works Anonymously When The Playback Configuration Allows It.") + .WithSummary("Get a streaming manifest URL and ticket for a track. Works anonymously when the playback configuration allows it") .Produces() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); - group.MapPut("/listens/{listenId}/progress", RecordListeningProgress) + group.MapPut("/listens/{listenId:guid}/progress", RecordListeningProgress) .AddEndpointFilter>() .WithName("RecordListeningProgress") - .WithSummary("Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint.") + .WithSummary("Report the total seconds actually played for a listen started by the stream endpoint") .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); - group.MapGet("/users/{userId}", GetTracksByUserId) + group.MapGet("/users/{userId:long}", GetTracksByUserId) .AddEndpointFilter>() .WithName("GetTracksByUserId") - .WithSummary("Get Paginated Tracks For A User.") + .WithSummary("Get paginated tracks for a user") .Produces>(); group.MapPost("/", CreateTrack) .WithName("CreateTrack") - .WithSummary("Create A New Track.") + .WithSummary("Create a new track") .AddEndpointFilter>() .RequireAuthorization() .Produces(StatusCodes.Status201Created) @@ -71,9 +71,9 @@ public static class TrackEndpoints .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); - group.MapDelete("/{trackId}", DeleteTrack) + group.MapDelete("/{id:guid}", DeleteTrack) .WithName("DeleteTrack") - .WithSummary("Delete A Track.") + .WithSummary("Delete a track") .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) @@ -83,7 +83,7 @@ public static class TrackEndpoints group.MapPost("/upload-urls", RequestTrackUploadUrls) .WithName("RequestTrackUploadUrls") - .WithSummary("Request Pre-Signed URLs To Upload Track Picture And Audio.") + .WithSummary("Request pre-signed URLs to upload track picture and audio") .AddEndpointFilter>() .RequireAuthorization() .Produces() @@ -176,10 +176,10 @@ public static class TrackEndpoints private static async Task DeleteTrack( IMediator mediator, CurrentUser currentUser, - Guid trackId, + Guid id, CancellationToken cancellationToken) { - var result = await mediator.Send(new DeleteTrackCommand(currentUser.RequiredId, trackId), cancellationToken); + var result = await mediator.Send(new DeleteTrackCommand(currentUser.RequiredId, id), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs index 5744d64..5eaac41 100644 --- a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -1,13 +1,13 @@ using Mediator; using Musify.Api.Authentication; using Musify.Api.Extensions; +using Musify.Api.Filters; using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; using Musify.Application.Users; using Musify.Application.Users.Responses; -using Musify.Api.Filters; namespace Musify.Api.Endpoints; @@ -21,75 +21,75 @@ public static class UserEndpoints group.MapGet("/", GetUsers) .AddEndpointFilter>() .WithName("GetUsers") - .WithSummary("Get Paginated Users.") + .WithSummary("Get paginated users") .Produces>(); - group.MapGet("/{id}", GetUserById) + group.MapGet("/{id:long}", GetUserById) .WithName("GetUserById") - .WithSummary("Get A User By Id.") + .WithSummary("Get a user by id") .Produces() .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id}/listening-history", GetListeningHistory) + group.MapGet("/{id:long}/listening-history", GetListeningHistory) .WithName("GetListeningHistory") - .WithSummary("Get A User'S Listening History.") + .WithSummary("Get a user's listening history") .Produces>() .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id}/listening-stats", GetListeningStats) + group.MapGet("/{id:long}/listening-stats", GetListeningStats) .WithName("GetListeningStats") - .WithSummary("Get A User'S Listening Stats For The Current Week And Streak.") + .WithSummary("Get a user's listening stats for the current week and streak") .RequireAuthorization() .Produces() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden); - group.MapGet("/{id}/last-listened-track", GetLastTrackListenedByUserId) + group.MapGet("/{id:long}/last-listened-track", GetLastTrackListenedByUserId) .WithName("GetLastTrackListenedByUserId") - .WithSummary("Get The Last Track A User Listened To.") + .WithSummary("Get the last track a user listened to") .Produces() .Produces(StatusCodes.Status204NoContent); - group.MapGet("/{id}/profile", GetUserProfile) + group.MapGet("/{id:long}/profile", GetUserProfile) .WithName("GetUserProfile") - .WithSummary("Get A User'S Public Profile, Including Follower Counts.") + .WithSummary("Get a user's public profile, including follower counts") .Produces() .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id}/followers", GetFollowers) + group.MapGet("/{id:long}/followers", GetFollowers) .AddEndpointFilter>() .WithName("GetUserFollowers") - .WithSummary("Get The Users Following A User. Visible To The User And To Mutual Followers.") + .WithSummary("Get the users following a user. Visible to the user and to mutual followers") .Produces>() .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id}/following", GetFollowing) + group.MapGet("/{id:long}/following", GetFollowing) .AddEndpointFilter>() .WithName("GetUserFollowing") - .WithSummary("Get The Users A User Follows.") + .WithSummary("Get the users a user follows") .Produces>() .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id}/is-following", IsFollowing) + group.MapGet("/{id:long}/is-following", IsFollowing) .WithName("IsFollowingUser") - .WithSummary("Check Whether The Current User Follows Another User.") + .WithSummary("Check whether the current user follows another user") .RequireAuthorization() .Produces() .Produces(StatusCodes.Status401Unauthorized); - group.MapPost("/{id}/follow", FollowUser) + group.MapPost("/{id:long}/follow", FollowUser) .WithName("FollowUser") - .WithSummary("Follow A User.") + .WithSummary("Follow a user") .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound) .ProducesValidationProblem(); - group.MapDelete("/{id}/follow", UnfollowUser) + group.MapDelete("/{id:long}/follow", UnfollowUser) .WithName("UnfollowUser") - .WithSummary("Unfollow A User.") + .WithSummary("Unfollow a user") .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized); @@ -117,12 +117,13 @@ public static class UserEndpoints return result.ToHttpResult(); } - private static async Task> GetListeningHistory( + private static async Task GetListeningHistory( IMediator mediator, long id, CancellationToken cancellationToken) { - return await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken); + var result = await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken); + return Results.Ok(result); } private static async Task GetListeningStats( @@ -132,9 +133,10 @@ public static class UserEndpoints CancellationToken cancellationToken) { if (currentUser.RequiredId != id) - return Results.StatusCode(StatusCodes.Status403Forbidden); + return Results.Problem(statusCode: StatusCodes.Status403Forbidden); - return Results.Ok(await mediator.Send(new GetListeningStatsQuery(id), cancellationToken)); + var result = await mediator.Send(new GetListeningStatsQuery(id), cancellationToken); + return Results.Ok(result); } private static async Task GetLastTrackListenedByUserId( @@ -178,13 +180,14 @@ public static class UserEndpoints return result.ToHttpResult(); } - private static async Task IsFollowing( + private static async Task IsFollowing( IMediator mediator, CurrentUser currentUser, long id, CancellationToken cancellationToken) { - return await mediator.Send(new IsFollowingUserQuery(currentUser.RequiredId, id), cancellationToken); + var result = await mediator.Send(new IsFollowingUserQuery(currentUser.RequiredId, id), cancellationToken); + return Results.Ok(result); } private static async Task FollowUser( diff --git a/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs index a7b1cbd..a1ae1ec 100644 --- a/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs @@ -9,9 +9,9 @@ namespace Musify.Api.OpenApi; public sealed class OpenApiOptionsSetup(IOptions options) : IConfigureNamedOptions { - private const string SecuritySchemeId = "OAuth2"; + internal const string SecuritySchemeId = "OAuth2"; - private readonly AuthenticationConfiguration _configuration = options.Value; + private readonly AuthenticationConfiguration configuration = options.Value; public void Configure(string? name, OpenApiOptions options) => Configure(options); @@ -28,9 +28,9 @@ public sealed class OpenApiOptionsSetup(IOptions op { AuthorizationCode = new OpenApiOAuthFlow { - AuthorizationUrl = new Uri(_configuration.AuthorizationEndpoint), - TokenUrl = new Uri(_configuration.TokenEndpoint), - Scopes = _configuration.Scopes.ToDictionary(scope => scope, scope => scope), + AuthorizationUrl = new Uri(configuration.AuthorizationEndpoint), + TokenUrl = new Uri(configuration.TokenEndpoint), + Scopes = configuration.Scopes.ToDictionary(scope => scope, scope => scope), }, }, }; @@ -57,6 +57,16 @@ public sealed class OpenApiOptionsSetup(IOptions op options.AddOperationTransformer((operation, context, _) => { + // Route parameters constrained with :long are user ids, which travel as strings like every other long. + foreach (var parameter in operation.Parameters?.OfType() ?? []) + { + if (parameter.Schema is OpenApiSchema { Type: JsonSchemaType.Integer, Format: "int64" } schema) + { + schema.Type = JsonSchemaType.String; + schema.Format = null; + } + } + var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata .OfType() .Any(); @@ -66,15 +76,13 @@ public sealed class OpenApiOptionsSetup(IOptions op .Any(); if (!requiresAuthorization || allowsAnonymous) - { return Task.CompletedTask; - } operation.Security ??= new List(); operation.Security.Add(new OpenApiSecurityRequirement { [new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] = - _configuration.Scopes.ToList(), + configuration.Scopes.ToList(), }); return Task.CompletedTask; diff --git a/backend/Hosts/Musify.Api/Program.cs b/backend/Hosts/Musify.Api/Program.cs index c1f1257..71b704f 100644 --- a/backend/Hosts/Musify.Api/Program.cs +++ b/backend/Hosts/Musify.Api/Program.cs @@ -5,7 +5,9 @@ using Musify.Api.Serialization; using Musify.Infrastructure.Observability; using Scalar.AspNetCore; -var builder = WebApplication.CreateBuilder(); +const string DevCorsPolicy = "dev-cors"; + +var builder = WebApplication.CreateBuilder(args); builder.Services.ConfigureHttpJsonOptions(options => { @@ -14,17 +16,16 @@ builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Converters.Add(new NullableLongAsStringConverter()); }); -const string devCorsPolicy = "dev-cors"; - builder.Services .AddApplicationServices(builder.Configuration) .AddObservability(builder.Configuration); builder.Services.AddProblemDetails(); +builder.Services.Configure(options => options.ThrowOnBadRequest = false); if (builder.Environment.IsDevelopment()) { - builder.Services.AddCors(options => options.AddPolicy(devCorsPolicy, policy => + builder.Services.AddCors(options => options.AddPolicy(DevCorsPolicy, policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); } @@ -34,7 +35,7 @@ app.UseExceptionHandler(); if (app.Environment.IsDevelopment()) { - app.UseCors(devCorsPolicy); + app.UseCors(DevCorsPolicy); app.MapOpenApi(); app.MapScalarApiReference(); } diff --git a/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs b/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs index de89db5..05e77b6 100644 --- a/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Options; using Musify.Api.Authentication; +using Musify.Api.OpenApi; using Scalar.AspNetCore; namespace Musify.Api.Scalar; @@ -7,25 +8,23 @@ namespace Musify.Api.Scalar; public sealed class ScalarOptionsSetup(IOptions options) : IConfigureOptions { - private const string SecuritySchemeId = "OAuth2"; - - private readonly AuthenticationConfiguration _configuration = options.Value; + private readonly AuthenticationConfiguration configuration = options.Value; public void Configure(ScalarOptions scalarOptions) { scalarOptions .WithTitle("Musify API") - .AddPreferredSecuritySchemes(SecuritySchemeId) - .AddAuthorizationCodeFlow(SecuritySchemeId, flow => + .AddPreferredSecuritySchemes(OpenApiOptionsSetup.SecuritySchemeId) + .AddAuthorizationCodeFlow(OpenApiOptionsSetup.SecuritySchemeId, flow => { - flow.ClientId = _configuration.ClientId; - flow.ClientSecret = _configuration.ClientSecret; - flow.AuthorizationUrl = _configuration.AuthorizationEndpoint; - flow.TokenUrl = _configuration.TokenEndpoint; + flow.ClientId = configuration.ClientId; + flow.ClientSecret = configuration.ClientSecret; + flow.AuthorizationUrl = configuration.AuthorizationEndpoint; + flow.TokenUrl = configuration.TokenEndpoint; flow.Pkce = Pkce.Sha256; - flow.SelectedScopes = _configuration.Scopes; - flow.RedirectUri = _configuration.ScalarRedirectUri; - flow.RefreshUrl = _configuration.TokenEndpoint; + flow.SelectedScopes = configuration.Scopes; + flow.RedirectUri = configuration.ScalarRedirectUri; + flow.RefreshUrl = configuration.TokenEndpoint; }) .HideModels(); } diff --git a/backend/Hosts/Musify.Api/Validators/Albums/AlbumReleaseYear.cs b/backend/Hosts/Musify.Api/Validators/Albums/AlbumReleaseYear.cs deleted file mode 100644 index 222c8be..0000000 --- a/backend/Hosts/Musify.Api/Validators/Albums/AlbumReleaseYear.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Musify.Api.Validators.Albums; - -public static class AlbumReleaseYear -{ - public const int Earliest = 1877; - - public static int Latest => DateTime.UtcNow.Year + 1; -} diff --git a/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs index 10550eb..0bb90b2 100644 --- a/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs @@ -9,13 +9,13 @@ public sealed class CreateAlbumRequestValidator : AbstractValidator x.Title) .NotEmpty() - .MaximumLength(200); + .MaximumLength(Limits.AlbumTitle); RuleFor(x => x.Description) - .MaximumLength(256); + .MaximumLength(Limits.Description); RuleFor(x => x.ReleaseYear) - .InclusiveBetween(AlbumReleaseYear.Earliest, AlbumReleaseYear.Latest) + .InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear) .When(x => x.ReleaseYear.HasValue); RuleFor(x => x.PictureIntentId) diff --git a/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs index 122a906..91620c6 100644 --- a/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs @@ -7,15 +7,15 @@ public sealed class UpdateAlbumRequestValidator : AbstractValidator x.NewTitle) + RuleFor(x => x.Title) .NotEmpty() - .MaximumLength(200); + .MaximumLength(Limits.AlbumTitle); - RuleFor(x => x.NewDescription) - .MaximumLength(256); + RuleFor(x => x.Description) + .MaximumLength(Limits.Description); - RuleFor(x => x.NewReleaseYear) - .InclusiveBetween(AlbumReleaseYear.Earliest, AlbumReleaseYear.Latest) - .When(x => x.NewReleaseYear.HasValue); + RuleFor(x => x.ReleaseYear) + .InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear) + .When(x => x.ReleaseYear.HasValue); } } diff --git a/backend/Hosts/Musify.Api/Validators/Limits.cs b/backend/Hosts/Musify.Api/Validators/Limits.cs new file mode 100644 index 0000000..ac782d8 --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Limits.cs @@ -0,0 +1,12 @@ +namespace Musify.Api.Validators; + +public static class Limits +{ + public const int TrackTitle = 200; + public const int AlbumTitle = 200; + public const int PlayListName = 50; + public const int Description = 256; + public const int EarliestReleaseYear = 1877; + + public static int LatestReleaseYear => DateTime.UtcNow.Year + 1; +} diff --git a/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PictureUploadRequestValidator.cs similarity index 55% rename from backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs rename to backend/Hosts/Musify.Api/Validators/PictureUploadRequestValidator.cs index 0db9287..15a9f40 100644 --- a/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PictureUploadRequestValidator.cs @@ -1,12 +1,12 @@ using FluentValidation; -using Musify.Api.DataTransferObjects.Albums; +using Musify.Api.DataTransferObjects; using Musify.Application.Configuration; -namespace Musify.Api.Validators.Albums; +namespace Musify.Api.Validators; -public sealed class RequestAlbumPictureUploadRequestValidator : AbstractValidator +public sealed class PictureUploadRequestValidator : AbstractValidator { - public RequestAlbumPictureUploadRequestValidator(UploadIntentConfiguration uploadConfiguration) + public PictureUploadRequestValidator(UploadIntentConfiguration uploadConfiguration) { RuleFor(x => x.FileType) .MustBeFileType(Uploads.PictureFileTypes); diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs index c28c91e..7695b3c 100644 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs @@ -9,10 +9,10 @@ public sealed class CreatePlayListRequestValidator : AbstractValidator x.Name) .NotEmpty() - .MaximumLength(50); + .MaximumLength(Limits.PlayListName); RuleFor(x => x.Description) - .MaximumLength(256); + .MaximumLength(Limits.Description); RuleFor(x => x.Visibility) .IsInEnum(); diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs deleted file mode 100644 index 7da850a..0000000 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentValidation; -using Musify.Api.DataTransferObjects.PlayLists; -using Musify.Application.Configuration; - -namespace Musify.Api.Validators.PlayLists; - -public sealed class RequestPlayListPictureUploadRequestValidator : AbstractValidator -{ - public RequestPlayListPictureUploadRequestValidator(UploadIntentConfiguration uploadConfiguration) - { - RuleFor(x => x.FileType) - .MustBeFileType(Uploads.PictureFileTypes); - - RuleFor(x => x.ContentType) - .MustBeContentType(Uploads.PictureContentTypes); - - RuleFor(x => x.ExpectedSizeBytes) - .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); - } -} diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs index c7bbc96..6ca36af 100644 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs @@ -7,17 +7,17 @@ public sealed class UpdatePlayListRequestValidator : AbstractValidator x.NewName) + RuleFor(x => x.Name) .NotEmpty() - .MaximumLength(50) - .When(x => x.NewName != null); + .MaximumLength(Limits.PlayListName) + .When(x => x.Name != null); - RuleFor(x => x.NewDescription) - .MaximumLength(256) - .When(x => x.NewDescription != null); + RuleFor(x => x.Description) + .MaximumLength(Limits.Description) + .When(x => x.Description != null); - RuleFor(x => x.NewVisibility) + RuleFor(x => x.Visibility) .IsInEnum() - .When(x => x.NewVisibility != null); + .When(x => x.Visibility != null); } } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs index 6584cff..266ba43 100644 --- a/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs @@ -10,7 +10,7 @@ public sealed class CreateTrackRequestValidator : AbstractValidator x.Title) .NotEmpty() - .MaximumLength(200); + .MaximumLength(Limits.TrackTitle); RuleFor(x => x.PictureIntentId) .NotEmpty(); diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index 6f6d5eb..4a7f5c9 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using Musify.Api.DataTransferObjects; using Musify.Api.DataTransferObjects.Albums; using Musify.Api.Tests.TestSupport; using Musify.Application.Albums.Responses; @@ -26,7 +27,7 @@ public sealed class AlbumEndpointsTests(ApiTestFixture fixture) var response = await client.PostAsJsonAsync( "/albums/upload-picture", - new RequestAlbumPictureUploadRequest("webp", "image/webp"), + new PictureUploadRequest("webp", "image/webp"), TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); diff --git a/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs index 7fe97e9..728c829 100644 --- a/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs +++ b/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs @@ -1,4 +1,5 @@ using Musify.Api.DataTransferObjects.Albums; +using Musify.Api.Validators; using Musify.Api.Validators.Albums; using Xunit; @@ -44,7 +45,7 @@ public sealed class CreateAlbumRequestValidatorTests [Fact] public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails() { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1, Guid.NewGuid())); + var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear - 1, Guid.NewGuid())); Assert.False(result.IsValid); } @@ -52,7 +53,7 @@ public sealed class CreateAlbumRequestValidatorTests [Fact] public void Validate_ReleaseYearAtTheEarliestBoundary_Passes() { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest, Guid.NewGuid())); + var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear, Guid.NewGuid())); Assert.True(result.IsValid); } @@ -60,7 +61,7 @@ public sealed class CreateAlbumRequestValidatorTests [Fact] public void Validate_ReleaseYearAtTheLatestBoundary_Passes() { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest, Guid.NewGuid())); + var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear, Guid.NewGuid())); Assert.True(result.IsValid); } @@ -68,7 +69,7 @@ public sealed class CreateAlbumRequestValidatorTests [Fact] public void Validate_ReleaseYearPastTheLatestBoundary_Fails() { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1, Guid.NewGuid())); + var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear + 1, Guid.NewGuid())); Assert.False(result.IsValid); } diff --git a/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs index f671b0e..e9b5fa3 100644 --- a/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs +++ b/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs @@ -1,6 +1,6 @@ -using Musify.Api.DataTransferObjects.Albums; +using Musify.Api.DataTransferObjects; using Musify.Api.DataTransferObjects.Tracks; -using Musify.Api.Validators.Albums; +using Musify.Api.Validators; using Musify.Api.Validators.Tracks; using Musify.Application.Configuration; using Xunit; @@ -14,9 +14,9 @@ public sealed class UploadRequestValidatorTests [Fact] public void PictureUpload_ValidRequest_Passes() { - var validator = new RequestAlbumPictureUploadRequestValidator(configuration); + var validator = new PictureUploadRequestValidator(configuration); - var result = validator.Validate(new RequestAlbumPictureUploadRequest("webp", "image/webp", 500)); + var result = validator.Validate(new PictureUploadRequest("webp", "image/webp", 500)); Assert.True(result.IsValid); } @@ -30,9 +30,9 @@ public sealed class UploadRequestValidatorTests [InlineData("webp", "image/webp", 1_001L)] public void PictureUpload_InvalidRequest_Fails(string fileType, string contentType, long size) { - var validator = new RequestAlbumPictureUploadRequestValidator(configuration); + var validator = new PictureUploadRequestValidator(configuration); - var result = validator.Validate(new RequestAlbumPictureUploadRequest(fileType, contentType, size)); + var result = validator.Validate(new PictureUploadRequest(fileType, contentType, size)); Assert.False(result.IsValid); } diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 16a265e..4b274e0 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -11,10 +11,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated Albums, Optionally Filtered By Title. */ + /** Get paginated albums, optionally filtered by title */ get: operations["GetAlbums"]; put?: never; - /** Create A New Album. */ + /** Create a new album */ post: operations["CreateAlbum"]; delete?: never; options?: never; @@ -29,11 +29,13 @@ export interface paths { path?: never; cookie?: never; }; - /** Get An Album By Id. */ + /** Get an album by id */ get: operations["GetAlbumById"]; - put?: never; + /** Update an album */ + put: operations["UpdateAlbum"]; post?: never; - delete?: never; + /** Delete an album */ + delete: operations["DeleteAlbum"]; options?: never; head?: never; patch?: never; @@ -46,7 +48,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated Albums For A User. */ + /** Get paginated albums for a user */ get: operations["GetAlbumsByUserId"]; put?: never; post?: never; @@ -63,7 +65,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get The Albums The Current User Listened To Most Recently. */ + /** Get the albums the current user listened to most recently */ get: operations["GetRecentlyListenedAlbums"]; put?: never; post?: never; @@ -73,17 +75,18 @@ export interface paths { patch?: never; trace?: never; }; - "/albums/{albumId}/tracks": { + "/albums/{id}/tracks": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get The Tracks Of An Album Ordered By Track Number. */ + /** Get the tracks of an album ordered by track number */ get: operations["GetAlbumTracks"]; put?: never; - post?: never; + /** Add your uploaded tracks to an album, skipping the ones it already has */ + post: operations["AddAlbumTracks"]; delete?: never; options?: never; head?: never; @@ -97,7 +100,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get An Album Cover Image. */ + /** Get an album cover image */ get: operations["GetAlbumCover"]; put?: never; post?: never; @@ -116,7 +119,7 @@ export interface paths { }; get?: never; put?: never; - /** Request A Pre-Signed URL To Upload An Album Picture. */ + /** Request a pre-signed URL to upload an album picture */ post: operations["RequestAlbumPictureUpload"]; delete?: never; options?: never; @@ -124,42 +127,7 @@ export interface paths { patch?: never; trace?: never; }; - "/albums/{albumId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - /** Update An Album. */ - put: operations["UpdateAlbum"]; - post?: never; - /** Delete An Album. */ - delete: operations["DeleteAlbum"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/albums/{id}/tracks": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Add Your Uploaded Tracks To An Album, Skipping The Ones It Already Has. */ - post: operations["AddAlbumTracks"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/albums/{albumId}/tracks/{trackId}": { + "/albums/{id}/tracks/{trackId}": { parameters: { query?: never; header?: never; @@ -169,7 +137,7 @@ export interface paths { get?: never; put?: never; post?: never; - /** Remove A Track From An Album. */ + /** Remove a track from an album */ delete: operations["RemoveTrackFromAlbum"]; options?: never; head?: never; @@ -183,7 +151,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get The Public Playback Configuration (Whether Anonymous Listening Is Allowed). Works Anonymously. */ + /** Get the public playback configuration (whether anonymous listening is allowed). Works anonymously */ get: operations["GetPlaybackPublicConfig"]; put?: never; post?: never; @@ -200,7 +168,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get The Genres That Have At Least One Track, Most Populated First. Works Anonymously. */ + /** Get the genres that have at least one track, most populated first. Works anonymously */ get: operations["GetGenres"]; put?: never; post?: never; @@ -217,7 +185,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Every Genre A Track Can Be Tagged With, Including The Genres It Cannot Be Combined With. Works Anonymously. */ + /** Get every genre a track can be tagged with, including the genres it cannot be combined with. Works anonymously */ get: operations["GetAvailableGenres"]; put?: never; post?: never; @@ -234,7 +202,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get The Current User'S Liked Tracks. */ + /** Get the current user's liked tracks */ get: operations["GetLikedTracks"]; put?: never; post?: never; @@ -253,7 +221,7 @@ export interface paths { }; get?: never; put?: never; - /** Like Or Unlike A Track By Id. */ + /** Like or unlike a track by id */ post: operations["ToggleTrackLike"]; delete?: never; options?: never; @@ -268,7 +236,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get The Server-Generated Mixes Of The Current User. */ + /** Get the server-generated mixes of the current user */ get: operations["GetMixes"]; put?: never; post?: never; @@ -285,7 +253,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A Mix With All Of Its Songs. */ + /** Get a mix with all of its songs */ get: operations["GetMixById"]; put?: never; post?: never; @@ -302,11 +270,13 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A PlayList By Id. */ + /** Get a playlist by id */ get: operations["GetPlayListById"]; - put?: never; + /** Update an existing playlist */ + put: operations["UpdatePlayList"]; post?: never; - delete?: never; + /** Delete a playlist */ + delete: operations["DeletePlayList"]; options?: never; head?: never; patch?: never; @@ -319,7 +289,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated PlayLists For A User. Private PlayLists Are Only Returned To Their Owner. */ + /** Get paginated playlists for a user. Private playlists are only returned to their owner */ get: operations["GetPlayListsByUserId"]; put?: never; post?: never; @@ -329,17 +299,18 @@ export interface paths { patch?: never; trace?: never; }; - "/playlists/{playlistId}/tracks": { + "/playlists/{id}/tracks": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get Paginated Tracks Of A PlayList. */ + /** Get paginated tracks of a playlist */ get: operations["GetPlayListTracks"]; put?: never; - post?: never; + /** Add tracks to a playlist, skipping the ones it already has */ + post: operations["AddPlayListTracks"]; delete?: never; options?: never; head?: never; @@ -353,7 +324,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A PlayList Cover Image. */ + /** Get a playlist cover image */ get: operations["GetPlayListCover"]; put?: never; post?: never; @@ -372,7 +343,7 @@ export interface paths { }; get?: never; put?: never; - /** Create A New PlayList. */ + /** Create a new playlist */ post: operations["CreatePlayList"]; delete?: never; options?: never; @@ -380,24 +351,6 @@ export interface paths { patch?: never; trace?: never; }; - "/playlists/{playlistId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - /** Update An Existing PlayList. */ - put: operations["UpdatePlayList"]; - post?: never; - /** Delete A PlayList. */ - delete: operations["DeletePlayList"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/playlists/upload-picture": { parameters: { query?: never; @@ -407,7 +360,7 @@ export interface paths { }; get?: never; put?: never; - /** Request A Pre-Signed URL To Upload A PlayList Picture. */ + /** Request a pre-signed URL to upload a playlist picture */ post: operations["RequestPlayListPictureUpload"]; delete?: never; options?: never; @@ -415,24 +368,7 @@ export interface paths { patch?: never; trace?: never; }; - "/playlists/{id}/tracks": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Add Tracks To A PlayList, Skipping The Ones It Already Has. */ - post: operations["AddPlayListTracks"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/playlists/{playlistId}/tracks/{trackId}": { + "/playlists/{id}/tracks/{trackId}": { parameters: { query?: never; header?: never; @@ -442,7 +378,7 @@ export interface paths { get?: never; put?: never; post?: never; - /** Remove A Track From A PlayList. */ + /** Remove a track from a playlist */ delete: operations["RemoveTrackFromPlayList"]; options?: never; head?: never; @@ -456,10 +392,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated Tracks, Optionally Filtered By Name And Genre. */ + /** Get paginated tracks, optionally filtered by name and genre */ get: operations["GetTracks"]; put?: never; - /** Create A New Track. */ + /** Create a new track */ post: operations["CreateTrack"]; delete?: never; options?: never; @@ -474,11 +410,12 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A Track By Id. */ + /** Get a track by id */ get: operations["GetTrackById"]; put?: never; post?: never; - delete?: never; + /** Delete a track */ + delete: operations["DeleteTrack"]; options?: never; head?: never; patch?: never; @@ -491,7 +428,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A Track Cover Image. */ + /** Get a track cover image */ get: operations["GetTrackCover"]; put?: never; post?: never; @@ -508,7 +445,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A Streaming Manifest URL And Ticket For A Track. Works Anonymously When The Playback Configuration Allows It. */ + /** Get a streaming manifest URL and ticket for a track. Works anonymously when the playback configuration allows it */ get: operations["GetTrackStream"]; put?: never; post?: never; @@ -526,7 +463,7 @@ export interface paths { cookie?: never; }; get?: never; - /** Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint. */ + /** Report the total seconds actually played for a listen started by the stream endpoint */ put: operations["RecordListeningProgress"]; post?: never; delete?: never; @@ -542,7 +479,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated Tracks For A User. */ + /** Get paginated tracks for a user */ get: operations["GetTracksByUserId"]; put?: never; post?: never; @@ -552,23 +489,6 @@ export interface paths { patch?: never; trace?: never; }; - "/tracks/{trackId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - /** Delete A Track. */ - delete: operations["DeleteTrack"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/tracks/upload-urls": { parameters: { query?: never; @@ -578,7 +498,7 @@ export interface paths { }; get?: never; put?: never; - /** Request Pre-Signed URLs To Upload Track Picture And Audio. */ + /** Request pre-signed URLs to upload track picture and audio */ post: operations["RequestTrackUploadUrls"]; delete?: never; options?: never; @@ -593,7 +513,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated Users. */ + /** Get paginated users */ get: operations["GetUsers"]; put?: never; post?: never; @@ -610,7 +530,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A User By Id. */ + /** Get a user by id */ get: operations["GetUserById"]; put?: never; post?: never; @@ -627,7 +547,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A User'S Listening History. */ + /** Get a user's listening history */ get: operations["GetListeningHistory"]; put?: never; post?: never; @@ -644,7 +564,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A User'S Listening Stats For The Current Week And Streak. */ + /** Get a user's listening stats for the current week and streak */ get: operations["GetListeningStats"]; put?: never; post?: never; @@ -661,7 +581,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get The Last Track A User Listened To. */ + /** Get the last track a user listened to */ get: operations["GetLastTrackListenedByUserId"]; put?: never; post?: never; @@ -678,7 +598,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A User'S Public Profile, Including Follower Counts. */ + /** Get a user's public profile, including follower counts */ get: operations["GetUserProfile"]; put?: never; post?: never; @@ -695,7 +615,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get The Users Following A User. Visible To The User And To Mutual Followers. */ + /** Get the users following a user. Visible to the user and to mutual followers */ get: operations["GetUserFollowers"]; put?: never; post?: never; @@ -712,7 +632,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get The Users A User Follows. */ + /** Get the users a user follows */ get: operations["GetUserFollowing"]; put?: never; post?: never; @@ -729,7 +649,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Check Whether The Current User Follows Another User. */ + /** Check whether the current user follows another user */ get: operations["IsFollowingUser"]; put?: never; post?: never; @@ -748,9 +668,9 @@ export interface paths { }; get?: never; put?: never; - /** Follow A User. */ + /** Follow a user */ post: operations["FollowUser"]; - /** Unfollow A User. */ + /** Unfollow a user */ delete: operations["UnfollowUser"]; options?: never; head?: never; @@ -910,6 +830,12 @@ export interface components { hasNextPage: boolean; hasPreviousPage: boolean; }; + PictureUploadRequest: { + fileType: string; + contentType: string; + /** Format: int64 */ + expectedSizeBytes?: null | number | string; + }; PictureUploadResponse: { /** Format: uuid */ intentId: string; @@ -954,18 +880,6 @@ export interface components { /** Format: double */ playedSeconds: number | string; }; - RequestAlbumPictureUploadRequest: { - fileType: string; - contentType: string; - /** Format: int64 */ - expectedSizeBytes?: null | number | string; - }; - RequestPlayListPictureUploadRequest: { - fileType: string; - contentType: string; - /** Format: int64 */ - expectedSizeBytes?: null | number | string; - }; RequestTrackUploadUrlsRequest: { pictureFileType: string; pictureContentType: string; @@ -1024,19 +938,19 @@ export interface components { expiresInSeconds: number | string; }; UpdateAlbumRequest: { - newTitle: string; - newDescription: null | string; + title: string; + description: null | string; /** Format: int32 */ - newReleaseYear: null | number | string; + releaseYear: null | number | string; /** Format: uuid */ - newPictureIntentId: null | string; + pictureIntentId: null | string; }; UpdatePlayListRequest: { - newName: null | string; - newDescription: null | string; + name: null | string; + description: null | string; /** Format: uuid */ - newPictureIntentId: null | string; - newVisibility?: null | components["schemas"]["PlayListVisibility"]; + pictureIntentId: null | string; + visibility?: null | components["schemas"]["PlayListVisibility"]; }; UserApplicationResponse: { id: string; @@ -1181,6 +1095,103 @@ export interface operations { }; }; }; + UpdateAlbum: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateAlbumRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AlbumApplicationResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + DeleteAlbum: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; GetAlbumsByUserId: { parameters: { query?: { @@ -1189,7 +1200,7 @@ export interface operations { }; header?: never; path: { - userId: unknown; + userId: string; }; cookie?: never; }; @@ -1250,7 +1261,7 @@ export interface operations { }; header?: never; path: { - albumId: string; + id: string; }; cookie?: never; }; @@ -1274,179 +1285,6 @@ export interface operations { }; }; }; - GetAlbumCover: { - parameters: { - query?: { - size?: string; - }; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - RequestAlbumPictureUpload: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["RequestAlbumPictureUploadRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["PictureUploadResponse"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - UpdateAlbum: { - parameters: { - query?: never; - header?: never; - path: { - albumId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateAlbumRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AlbumApplicationResponse"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - DeleteAlbum: { - parameters: { - query?: never; - header?: never; - path: { - albumId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; AddAlbumTracks: { parameters: { query?: never; @@ -1508,12 +1346,88 @@ export interface operations { }; }; }; + GetAlbumCover: { + parameters: { + query?: { + size?: string; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + RequestAlbumPictureUpload: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PictureUploadRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PictureUploadResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; RemoveTrackFromAlbum: { parameters: { query?: never; header?: never; path: { - albumId: string; + id: string; trackId: string; }; cookie?: never; @@ -1770,6 +1684,103 @@ export interface operations { }; }; }; + UpdatePlayList: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdatePlayListRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PlayListApplicationResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + DeletePlayList: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; GetPlayListsByUserId: { parameters: { query?: { @@ -1779,7 +1790,7 @@ export interface operations { }; header?: never; path: { - userId: unknown; + userId: string; }; cookie?: never; }; @@ -1804,7 +1815,7 @@ export interface operations { }; header?: never; path: { - playlistId: string; + id: string; }; cookie?: never; }; @@ -1828,6 +1839,67 @@ export interface operations { }; }; }; + AddPlayListTracks: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddTracksRequest"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; GetPlayListCover: { parameters: { query?: { @@ -1904,103 +1976,6 @@ export interface operations { }; }; }; - UpdatePlayList: { - parameters: { - query?: never; - header?: never; - path: { - playlistId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdatePlayListRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["PlayListApplicationResponse"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - DeletePlayList: { - parameters: { - query?: never; - header?: never; - path: { - playlistId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; RequestPlayListPictureUpload: { parameters: { query?: never; @@ -2010,7 +1985,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["RequestPlayListPictureUploadRequest"]; + "application/json": components["schemas"]["PictureUploadRequest"]; }; }; responses: { @@ -2048,73 +2023,12 @@ export interface operations { }; }; }; - AddPlayListTracks: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AddTracksRequest"]; - }; - }; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; RemoveTrackFromPlayList: { parameters: { query?: never; header?: never; path: { - playlistId: string; + id: string; trackId: string; }; cookie?: never; @@ -2268,6 +2182,54 @@ export interface operations { }; }; }; + DeleteTrack: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; GetTrackCover: { parameters: { query?: { @@ -2380,7 +2342,7 @@ export interface operations { }; header?: never; path: { - userId: unknown; + userId: string; }; cookie?: never; }; @@ -2397,54 +2359,6 @@ export interface operations { }; }; }; - DeleteTrack: { - parameters: { - query?: never; - header?: never; - path: { - trackId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; RequestTrackUploadUrls: { parameters: { query?: never; @@ -2528,7 +2442,7 @@ export interface operations { query?: never; header?: never; path: { - id: unknown; + id: string; }; cookie?: never; }; @@ -2557,7 +2471,7 @@ export interface operations { query?: never; header?: never; path: { - id: unknown; + id: string; }; cookie?: never; }; @@ -2586,7 +2500,7 @@ export interface operations { query?: never; header?: never; path: { - id: unknown; + id: string; }; cookie?: never; }; @@ -2622,7 +2536,7 @@ export interface operations { query?: never; header?: never; path: { - id: unknown; + id: string; }; cookie?: never; }; @@ -2651,7 +2565,7 @@ export interface operations { query?: never; header?: never; path: { - id: unknown; + id: string; }; cookie?: never; }; @@ -2683,7 +2597,7 @@ export interface operations { }; header?: never; path: { - id: unknown; + id: string; }; cookie?: never; }; @@ -2722,7 +2636,7 @@ export interface operations { }; header?: never; path: { - id: unknown; + id: string; }; cookie?: never; }; @@ -2751,7 +2665,7 @@ export interface operations { query?: never; header?: never; path: { - id: unknown; + id: string; }; cookie?: never; }; @@ -2780,7 +2694,7 @@ export interface operations { query?: never; header?: never; path: { - id: unknown; + id: string; }; cookie?: never; }; @@ -2823,7 +2737,7 @@ export interface operations { query?: never; header?: never; path: { - id: unknown; + id: string; }; cookie?: never; }; diff --git a/web-player/src/lib/server/playlistActions.ts b/web-player/src/lib/server/playlistActions.ts index 2f93141..3379aea 100644 --- a/web-player/src/lib/server/playlistActions.ts +++ b/web-player/src/lib/server/playlistActions.ts @@ -40,8 +40,8 @@ export async function addAlbumToPlaylistAction({ request, params, locals, fetch } const api = createApiClient({ fetch, accessToken }); - const { data: tracks, error: tracksErr } = await api.GET('/albums/{albumId}/tracks', { - params: { path: { albumId }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } } + const { data: tracks, error: tracksErr } = await api.GET('/albums/{id}/tracks', { + params: { path: { id: albumId }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } } }); if (tracksErr || !tracks) { diff --git a/web-player/src/routes/(app)/albums/[id]/+page.server.ts b/web-player/src/routes/(app)/albums/[id]/+page.server.ts index e3d2b68..82bb728 100644 --- a/web-player/src/routes/(app)/albums/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/albums/[id]/+page.server.ts @@ -18,9 +18,9 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent const [albumRes, tracksRes, libraryRes] = await Promise.all([ api.GET('/albums/{id}', { params: { path: { id: params.id } } }), - api.GET('/albums/{albumId}/tracks', { + api.GET('/albums/{id}/tracks', { params: { - path: { albumId: params.id }, + path: { id: params.id }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } } }), @@ -71,8 +71,8 @@ export const actions: Actions = { if (!trackId) return fail(400, { message: 'Falta la canción.' }); const api = createApiClient({ fetch, accessToken }); - const result = await api.DELETE('/albums/{albumId}/tracks/{trackId}', { - params: { path: { albumId: params.id, trackId } } + const result = await api.DELETE('/albums/{id}/tracks/{trackId}', { + params: { path: { id: params.id, trackId } } }); const failure = unwrapOrFail(result, 'No se pudo quitar la canción.'); if (failure) return failure; @@ -102,13 +102,13 @@ export const actions: Actions = { newPictureIntentId = uploaded.intentId; } - const result = await api.PUT('/albums/{albumId}', { - params: { path: { albumId: params.id } }, + const result = await api.PUT('/albums/{id}', { + params: { path: { id: params.id } }, body: { - newTitle: parsed.body.title, - newDescription: parsed.body.description, - newReleaseYear: parsed.body.releaseYear, - newPictureIntentId + title: parsed.body.title, + description: parsed.body.description, + releaseYear: parsed.body.releaseYear, + pictureIntentId: newPictureIntentId } }); const failure = unwrapOrFail(result, 'No se pudo actualizar el álbum.'); @@ -121,8 +121,8 @@ export const actions: Actions = { if (typeof accessToken !== 'string') return accessToken; const api = createApiClient({ fetch, accessToken }); - const result = await api.DELETE('/albums/{albumId}', { - params: { path: { albumId: params.id } } + const result = await api.DELETE('/albums/{id}', { + params: { path: { id: params.id } } }); const failure = unwrapOrFail(result, 'No se pudo eliminar el álbum.'); if (failure) return failure; diff --git a/web-player/src/routes/(app)/library/+page.server.ts b/web-player/src/routes/(app)/library/+page.server.ts index 3a49811..a0fccb1 100644 --- a/web-player/src/routes/(app)/library/+page.server.ts +++ b/web-player/src/routes/(app)/library/+page.server.ts @@ -28,8 +28,8 @@ export const actions: Actions = { if (!trackId) return fail(400, { message: 'Falta la canción.' }); const api = createApiClient({ fetch, accessToken }); - const result = await api.DELETE('/tracks/{trackId}', { - params: { path: { trackId } } + const result = await api.DELETE('/tracks/{id}', { + params: { path: { id: trackId } } }); const failure = unwrapOrFail(result, 'No se pudo borrar la canción.'); if (failure) return failure; diff --git a/web-player/src/routes/(app)/playlists/+page.server.ts b/web-player/src/routes/(app)/playlists/+page.server.ts index 8b41e64..a710d2c 100644 --- a/web-player/src/routes/(app)/playlists/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/+page.server.ts @@ -26,8 +26,8 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { fetchAllPages( async (pageNumber, pageSize) => ( - await api.GET('/playlists/{playlistId}/tracks', { - params: { path: { playlistId: playlist.id }, query: { pageNumber, pageSize } } + await api.GET('/playlists/{id}/tracks', { + params: { path: { id: playlist.id }, query: { pageNumber, pageSize } } }) ).data, PLAYLIST_TRACKS_LIMIT diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts index 32882b6..9bae75f 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts @@ -23,8 +23,8 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent fetchAllPages( async (pageNumber, pageSize) => ( - await api.GET('/playlists/{playlistId}/tracks', { - params: { path: { playlistId: params.id }, query: { pageNumber, pageSize } } + await api.GET('/playlists/{id}/tracks', { + params: { path: { id: params.id }, query: { pageNumber, pageSize } } }) ).data, PLAYLIST_TRACKS_LIMIT @@ -44,8 +44,8 @@ export const actions: Actions = { if (!trackId) return fail(400, { message: 'Falta la canción.' }); const api = createApiClient({ fetch, accessToken }); - const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', { - params: { path: { playlistId: params.id, trackId } } + const result = await api.DELETE('/playlists/{id}/tracks/{trackId}', { + params: { path: { id: params.id, trackId } } }); const failure = unwrapOrFail(result, 'No se pudo quitar la canción.'); if (failure) return failure; @@ -78,9 +78,14 @@ export const actions: Actions = { newPictureIntentId = result.intentId; } - const result = await api.PUT('/playlists/{playlistId}', { - params: { path: { playlistId: params.id } }, - body: { newName: name, newDescription: description, newPictureIntentId, newVisibility } + const result = await api.PUT('/playlists/{id}', { + params: { path: { id: params.id } }, + body: { + name, + description, + pictureIntentId: newPictureIntentId, + visibility: newVisibility + } }); const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.'); if (failure) return failure; @@ -92,8 +97,8 @@ export const actions: Actions = { if (typeof accessToken !== 'string') return accessToken; const api = createApiClient({ fetch, accessToken }); - const result = await api.DELETE('/playlists/{playlistId}', { - params: { path: { playlistId: params.id } } + const result = await api.DELETE('/playlists/{id}', { + params: { path: { id: params.id } } }); const failure = unwrapOrFail(result, 'No se pudo eliminar la playlist.'); if (failure) return failure; diff --git a/web-player/src/routes/(app)/user/[id]/+page.server.ts b/web-player/src/routes/(app)/user/[id]/+page.server.ts index 2760ce2..7f64317 100644 --- a/web-player/src/routes/(app)/user/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/user/[id]/+page.server.ts @@ -25,8 +25,8 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent const tracksByPlaylist = await Promise.all( playlistItems.map((playlist) => api - .GET('/playlists/{playlistId}/tracks', { - params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 1 } } + .GET('/playlists/{id}/tracks', { + params: { path: { id: playlist.id }, query: { pageNumber: 1, pageSize: 1 } } }) .then((res) => Number(res.data?.totalItemCount ?? 0)) ) diff --git a/web-player/src/routes/+page.server.ts b/web-player/src/routes/+page.server.ts index 791961b..35a1c2d 100644 --- a/web-player/src/routes/+page.server.ts +++ b/web-player/src/routes/+page.server.ts @@ -45,9 +45,9 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { const spotlightPlaylist = playlistItems[0] ?? null; const spotlightTracksPromise = spotlightPlaylist ? api - .GET('/playlists/{playlistId}/tracks', { + .GET('/playlists/{id}/tracks', { params: { - path: { playlistId: spotlightPlaylist.id }, + path: { id: spotlightPlaylist.id }, query: { pageNumber: 1, pageSize: HOME_SPOTLIGHT_TRACKS_LIMIT } } }) diff --git a/web-player/src/routes/api/albums/[id]/tracks/+server.ts b/web-player/src/routes/api/albums/[id]/tracks/+server.ts index e356c9c..6c9ff1c 100644 --- a/web-player/src/routes/api/albums/[id]/tracks/+server.ts +++ b/web-player/src/routes/api/albums/[id]/tracks/+server.ts @@ -5,9 +5,9 @@ import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config'; export const GET: RequestHandler = async ({ params, locals, fetch }) => { const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); - const result = await api.GET('/albums/{albumId}/tracks', { + const result = await api.GET('/albums/{id}/tracks', { params: { - path: { albumId: params.id }, + path: { id: params.id }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } } }); From c4cf5af7f1ca311a3e7bba98dcdf0a448573409f Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 12:40:18 +0200 Subject: [PATCH 14/21] Make the Worker validate its service container and align Worker and Gateway hosts The Worker now registers the Application layer, observability and stream tickets, validates the container on build and ships a complete appsettings. The Gateway drops underscore fields and uses a placeholder bucket in its default YARP transform. --- .../MassTransitDependencyInjection.cs | 3 +- .../Services/ServicesDependencyInjection.cs | 7 ---- .../Authentication/TicketValidator.cs | 27 +++++++------- .../Hosts/Musify.StreamingGateway/Program.cs | 11 +++--- .../appsettings.Development.json | 9 +++++ .../Musify.StreamingGateway/appsettings.json | 2 +- backend/Hosts/Musify.Worker/Program.cs | 33 ++++++++--------- .../appsettings.Development.json | 9 ++--- backend/Hosts/Musify.Worker/appsettings.json | 35 +++++++++++++++++++ deploy/compose.yml | 8 +++++ 10 files changed, 92 insertions(+), 52 deletions(-) diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs index 8ed3d83..f5ec69a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs @@ -14,8 +14,8 @@ public static partial class MassTransitDependencyInjection public static IServiceCollection AddMassTransitClient(this IServiceCollection services, IConfiguration configuration) { services.AddValidatedOptions(configuration); - services.AddScoped(); + services.AddMassTransit(options => { AddOutbox(options); @@ -34,6 +34,7 @@ public static partial class MassTransitDependencyInjection services.AddValidatedOptions(configuration); services.AddValidatedOptions(configuration); + services.AddScoped(); RegisterRoutingSlipBuilders(services); diff --git a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index 0ad4da5..47e9c43 100644 --- a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -38,13 +38,6 @@ public static class ServicesDependencyInjection return services; } - public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection services, IConfiguration configuration) - { - services.AddValidatedOptions(configuration); - - return services; - } - public static IServiceCollection AddJobs(this IServiceCollection services) { services.AddHangfire((serviceProvider, hangfireConfiguration) => diff --git a/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs b/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs index 5782c4b..3a27213 100644 --- a/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs +++ b/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs @@ -14,27 +14,24 @@ public sealed record TicketValidationResult(string Prefix, long? MaxBytes) public sealed class TicketValidator : IDisposable { - private readonly StreamTicketValidationConfiguration _options; - private readonly RSA _rsa; - private readonly TokenValidationParameters _validationParameters; - private readonly JsonWebTokenHandler _handler = new(); + private readonly RSA rsa; + private readonly TokenValidationParameters validationParameters; + private readonly JsonWebTokenHandler handler = new(); - public TicketValidator(StreamTicketValidationConfiguration options) + public TicketValidator(StreamTicketValidationConfiguration configuration) { - _options = options; + rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(configuration.PublicKeyPath)); - _rsa = RSA.Create(); - _rsa.ImportFromPem(File.ReadAllText(_options.PublicKeyPath)); - - _validationParameters = new TokenValidationParameters + validationParameters = new TokenValidationParameters { ValidateIssuer = true, - ValidIssuer = _options.Issuer, + ValidIssuer = configuration.Issuer, ValidateAudience = true, - ValidAudience = _options.Audience, + ValidAudience = configuration.Audience, ValidateLifetime = true, ValidateIssuerSigningKey = true, - IssuerSigningKey = new RsaSecurityKey(_rsa), + IssuerSigningKey = new RsaSecurityKey(rsa), ValidAlgorithms = [SecurityAlgorithms.RsaSha256], ClockSkew = TimeSpan.FromSeconds(30), }; @@ -45,7 +42,7 @@ public sealed class TicketValidator : IDisposable if (string.IsNullOrWhiteSpace(token)) return TicketValidationResult.Invalid; - var result = await _handler.ValidateTokenAsync(token, _validationParameters); + var result = await handler.ValidateTokenAsync(token, validationParameters); if (!result.IsValid) return TicketValidationResult.Invalid; @@ -59,5 +56,5 @@ public sealed class TicketValidator : IDisposable return new TicketValidationResult(prefix, maxBytes); } - public void Dispose() => _rsa.Dispose(); + public void Dispose() => rsa.Dispose(); } diff --git a/backend/Hosts/Musify.StreamingGateway/Program.cs b/backend/Hosts/Musify.StreamingGateway/Program.cs index 2997e5d..c127bd7 100644 --- a/backend/Hosts/Musify.StreamingGateway/Program.cs +++ b/backend/Hosts/Musify.StreamingGateway/Program.cs @@ -3,7 +3,9 @@ using Musify.StreamingGateway.Authentication; using Musify.StreamingGateway.Configuration; using Musify.StreamingGateway.Middleware; -var builder = WebApplication.CreateBuilder(); +const string CorsPolicy = "media-clients"; + +var builder = WebApplication.CreateBuilder(args); builder.Services .AddOptions() @@ -16,9 +18,8 @@ builder.Services.AddSingleton(serviceProvider => builder.Services.AddSingleton(); -const string corsPolicy = "media-clients"; var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; -builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => +builder.Services.AddCors(options => options.AddPolicy(CorsPolicy, policy => { if (builder.Environment.IsDevelopment()) policy.AllowAnyOrigin(); @@ -37,9 +38,9 @@ builder.Services var app = builder.Build(); app.MapGet("/health", () => Results.Ok("healthy")) - .WithSummary("Health Check Endpoint"); + .WithSummary("Health check endpoint"); -app.UseCors(corsPolicy); +app.UseCors(CorsPolicy); app.UseMiddleware(); app.MapReverseProxy(); diff --git a/backend/Hosts/Musify.StreamingGateway/appsettings.Development.json b/backend/Hosts/Musify.StreamingGateway/appsettings.Development.json index 0bac6c4..9a8d81e 100644 --- a/backend/Hosts/Musify.StreamingGateway/appsettings.Development.json +++ b/backend/Hosts/Musify.StreamingGateway/appsettings.Development.json @@ -1,5 +1,14 @@ { "StreamTicket": { "PublicKeyPath": "../../../deploy/keys/stream_public.pem" + }, + "ReverseProxy": { + "Routes": { + "media": { + "Transforms": [ + { "PathPattern": "/buckets/webapi-storage/{**rest}" } + ] + } + } } } diff --git a/backend/Hosts/Musify.StreamingGateway/appsettings.json b/backend/Hosts/Musify.StreamingGateway/appsettings.json index a2eeaa5..52df1f0 100644 --- a/backend/Hosts/Musify.StreamingGateway/appsettings.json +++ b/backend/Hosts/Musify.StreamingGateway/appsettings.json @@ -26,7 +26,7 @@ "ClusterId": "seaweed-filer", "Match": { "Path": "/media/{**rest}" }, "Transforms": [ - { "PathPattern": "/buckets/webapi-storage/{**rest}" } + { "PathPattern": "/buckets/CHANGE_ME/{**rest}" } ] } }, diff --git a/backend/Hosts/Musify.Worker/Program.cs b/backend/Hosts/Musify.Worker/Program.cs index 0ea3523..f377677 100644 --- a/backend/Hosts/Musify.Worker/Program.cs +++ b/backend/Hosts/Musify.Worker/Program.cs @@ -1,32 +1,27 @@ -using Musify.Application.Configuration; +using Musify.Application; using Musify.Infrastructure.MassTransit; +using Musify.Infrastructure.Observability; using Musify.Infrastructure.Persistence; using Musify.Infrastructure.Services; -var builder = Host.CreateApplicationBuilder(); +var builder = Host.CreateApplicationBuilder(args); builder.ConfigureContainer(new DefaultServiceProviderFactory(new ServiceProviderOptions { - ValidateOnBuild = false, + ValidateOnBuild = true, ValidateScopes = true })); -var configuration = builder.Configuration; - -var services = builder.Services; - -services.AddValidatedOptions(configuration); -services.AddValidatedOptions(configuration); -services.AddValidatedOptions(configuration); -services.AddValidatedOptions(configuration); -services.AddStorageService(configuration); -services.AddAudioTranscoder(configuration); -services.AddPictureService(); -services.AddDatabase(configuration); -services.AddMassTransitConsumers(configuration); -services.AddUploadIntentConfiguration(configuration); -services.AddMediator(); -services.AddJobs(); +builder.Services + .AddApplication(builder.Configuration) + .AddObservability(builder.Configuration) + .AddDatabase(builder.Configuration) + .AddStorageService(builder.Configuration) + .AddAudioTranscoder(builder.Configuration) + .AddStreamTicketService(builder.Configuration) + .AddPictureService() + .AddMassTransitConsumers(builder.Configuration) + .AddJobs(); var host = builder.Build(); diff --git a/backend/Hosts/Musify.Worker/appsettings.Development.json b/backend/Hosts/Musify.Worker/appsettings.Development.json index 3b8dee5..9ff1979 100644 --- a/backend/Hosts/Musify.Worker/appsettings.Development.json +++ b/backend/Hosts/Musify.Worker/appsettings.Development.json @@ -26,10 +26,11 @@ "ApplicationStorage": { "Bucket": "webapi-storage" }, - "AudioTranscoder": { - "Routes": { - "WorkingDirectory": "../../../deploy/.tmp" - } + "StreamGateway": { + "PublicBaseUrl": "http://localhost:8081" + }, + "StreamTicket": { + "PrivateKeyPath": "../../../deploy/keys/stream_private.pem" }, "Workers": { "Routes": { diff --git a/backend/Hosts/Musify.Worker/appsettings.json b/backend/Hosts/Musify.Worker/appsettings.json index b7f7360..d4fccda 100644 --- a/backend/Hosts/Musify.Worker/appsettings.json +++ b/backend/Hosts/Musify.Worker/appsettings.json @@ -26,11 +26,27 @@ "ApplicationStorage": { "Bucket": "" }, + "StreamGateway": { + "PublicBaseUrl": "", + "AudioFileName": "audio.m4a" + }, + "StreamTicket": { + "PrivateKeyPath": "", + "Audience": "media-gateway", + "Issuer": "musify-webapi", + "TicketTtlSeconds": 3600 + }, + "Playback": { + "AllowAnonymousListening": false, + "AnonymousFragmentSeconds": 0, + "EstimatedAudioBytesPerSecond": 16000 + }, "Mix": { "ItemsPerMix": 12 }, "PlayList": { "Routes": { + "UploadsFolder": "uploads", "ParentFolder": "PlayLists", "OriginalPicturesFolder": "OriginalPictures", "SmallPicturesFolder": "SmallPictures", @@ -46,8 +62,27 @@ "LargeHeight": 512 } }, + "Album": { + "Routes": { + "UploadsFolder": "uploads", + "ParentFolder": "Albums", + "OriginalPicturesFolder": "OriginalPictures", + "SmallPicturesFolder": "SmallPictures", + "MediumPicturesFolder": "MediumPictures", + "LargePicturesFolder": "LargePictures" + }, + "PicturesSizes": { + "SmallWidth": 128, + "SmallHeight": 128, + "MediumWidth": 256, + "MediumHeight": 256, + "LargeWidth": 512, + "LargeHeight": 512 + } + }, "Track": { "Routes": { + "UploadsFolder": "uploads", "ParentFolder": "Tracks", "OriginalPicturesFolder": "OriginalPictures", "SmallPicturesFolder": "SmallPictures", diff --git a/deploy/compose.yml b/deploy/compose.yml index 9d7d976..abcb832 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -340,10 +340,18 @@ services: depends_on: migrate: condition: service_completed_successfully + keys-init: + condition: service_completed_successfully environment: <<: *backend-env DOTNET_ENVIRONMENT: ${DOTNET_ENVIRONMENT} + # The Application handlers need these to build their dependencies; the + # Worker never issues stream tickets, so the key is never read. + StreamGateway__PublicBaseUrl: ${PUBLIC_STREAM_URL} + StreamTicket__PrivateKeyPath: /keys/stream_private.pem Workers__Routes__TemporaryFilesDirectory: /tmp/musify + volumes: + - ./keys:/keys:ro restart: unless-stopped # YARP in front of the SeaweedFS filer: validates the RS256 stream ticket and From bc6ca3651fa4f143df264640406261ae52e4d4aa Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 12:41:05 +0200 Subject: [PATCH 15/21] Share the entity model between Database and the test database via ModelConfiguration --- .../Persistence/Database.cs | 140 +--------------- .../Persistence/ModelConfiguration.cs | 154 ++++++++++++++++++ .../Application.Tests.csproj | 1 + .../TestSupport/TestDatabase.cs | 99 +---------- 4 files changed, 158 insertions(+), 236 deletions(-) create mode 100644 backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index 0814c5c..929c3db 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -6,7 +6,6 @@ using Musify.Application.Contracts; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Musify.Infrastructure.Configuration; -using Musify.Infrastructure.MassTransit.Sagas; using Npgsql; namespace Musify.Infrastructure.Persistence; @@ -31,144 +30,7 @@ public class Database(DatabaseConfiguration configuration) 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); - - modelBuilder.Entity() - .OwnsOne(track => track.Pictures, pictures => - { - pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); - pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64); - pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64); - pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64); - pictures.Property(p => p.ProcessingStatus).HasColumnName("PicturesProcessingStatus"); - }); - modelBuilder.Entity().Navigation(track => track.Pictures).IsRequired(); - - modelBuilder.Entity() - .OwnsOne(track => track.Audio, audio => - { - audio.Property(a => a.OriginalName).HasColumnName("OriginalAudioName").HasMaxLength(64); - audio.Property(a => a.FolderName).HasColumnName("AudioFolderName").HasMaxLength(64); - audio.Property(a => a.TranscodeStatus).HasColumnName("AudioTranscodeProcessingStatus"); - }); - modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); - - modelBuilder.Entity() - .HasOne(track => track.Owner) - .WithMany() - .HasForeignKey(track => track.OwnerUserId) - .OnDelete(DeleteBehavior.Restrict); - - 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(album => album.Pictures, pictures => - { - pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); - pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false); - pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false); - pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false); - }); - modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); - - modelBuilder.Entity() - .HasOne(album => album.Owner) - .WithMany() - .HasForeignKey(album => album.OwnerUserId) - .OnDelete(DeleteBehavior.Restrict); - - modelBuilder.Entity() - .HasOne(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() - .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) - .IsUnique(); - - modelBuilder.Entity() - .HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId }) - .IsUnique(); - - 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.Track) - .WithMany() - .HasForeignKey(item => item.TrackId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasIndex(item => new { item.MixId, item.Position }); - - 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() - .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.Followed) - .WithMany() - .HasForeignKey(follow => follow.FollowedId) - .OnDelete(DeleteBehavior.Restrict); - - 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 albumProcessing = modelBuilder.Entity(); - albumProcessing.HasKey(state => state.CorrelationId); - albumProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); - albumProcessing.Property(state => state.CurrentState).HasMaxLength(64); + ModelConfiguration.Apply(modelBuilder); } public DbSet Users => Set(); diff --git a/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs b/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs new file mode 100644 index 0000000..4e44ae1 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs @@ -0,0 +1,154 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Sagas; + +namespace Musify.Infrastructure.Persistence; + +/// +/// Entity model shared by and the in-memory test database, so both always agree. +/// +public static class ModelConfiguration +{ + public static void Apply(ModelBuilder modelBuilder) + { + var trackProcessing = modelBuilder.Entity(); + trackProcessing.HasKey(state => state.CorrelationId); + trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); + trackProcessing.Property(state => state.CurrentState).HasMaxLength(64); + + modelBuilder.Entity() + .OwnsOne(track => track.Pictures, pictures => + { + pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); + pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64); + pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64); + pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64); + pictures.Property(p => p.ProcessingStatus).HasColumnName("PicturesProcessingStatus"); + }); + modelBuilder.Entity().Navigation(track => track.Pictures).IsRequired(); + + modelBuilder.Entity() + .OwnsOne(track => track.Audio, audio => + { + audio.Property(a => a.OriginalName).HasColumnName("OriginalAudioName").HasMaxLength(64); + audio.Property(a => a.FolderName).HasColumnName("AudioFolderName").HasMaxLength(64); + audio.Property(a => a.TranscodeStatus).HasColumnName("AudioTranscodeProcessingStatus"); + }); + modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); + + modelBuilder.Entity() + .HasOne(track => track.Owner) + .WithMany() + .HasForeignKey(track => track.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .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(album => album.Pictures, pictures => + { + pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); + pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false); + }); + modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); + + modelBuilder.Entity() + .HasOne(album => album.Owner) + .WithMany() + .HasForeignKey(album => album.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(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() + .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId }) + .IsUnique(); + + 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.Track) + .WithMany() + .HasForeignKey(item => item.TrackId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(item => new { item.MixId, item.Position }); + + 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() + .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.Followed) + .WithMany() + .HasForeignKey(follow => follow.FollowedId) + .OnDelete(DeleteBehavior.Restrict); + + 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 albumProcessing = modelBuilder.Entity(); + albumProcessing.HasKey(state => state.CorrelationId); + albumProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); + albumProcessing.Property(state => state.CurrentState).HasMaxLength(64); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Application.Tests.csproj b/backend/Tests/Musify.Application.Tests/Application.Tests.csproj index d6f7af5..9a4f183 100644 --- a/backend/Tests/Musify.Application.Tests/Application.Tests.csproj +++ b/backend/Tests/Musify.Application.Tests/Application.Tests.csproj @@ -27,6 +27,7 @@ + diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs index 081ced1..b995a40 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -3,6 +3,7 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Domain.Entities; +using Musify.Infrastructure.Persistence; namespace Musify.Application.Tests.TestSupport; @@ -34,103 +35,7 @@ public sealed class TestDatabase : DbContext, IDatabase { 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 => - { - 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().OwnsOne(album => album.Pictures); - 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(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() - .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) - .IsUnique(); - - modelBuilder.Entity() - .HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId }) - .IsUnique(); - - 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.Track) - .WithMany() - .HasForeignKey(item => item.TrackId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasIndex(item => new { item.MixId, item.Position }); - - 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() - .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.Followed) - .WithMany() - .HasForeignKey(follow => follow.FollowedId) - .OnDelete(DeleteBehavior.Restrict); - - modelBuilder.Entity() - .HasIndex(follow => new { follow.FollowerId, follow.FollowedId }) - .IsUnique(); + ModelConfiguration.Apply(modelBuilder); } public DbSet Users => Set(); From 2d0867e68ab09378bcf11ff161d9a9777aea6754 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 12:53:33 +0200 Subject: [PATCH 16/21] Add tests for MassTransit activities, routing slips, sagas, fault handling and playlist visibility Infrastructure exposes its internals to the test project so the activities and slip builders can be exercised directly, and the fault consumer and state machines run on the MassTransit test harness. --- .../Infrastructure.csproj | 6 + .../Endpoints/PlayListEndpointsTests.cs | 156 +++++++++++ .../Jobs/RecurringJobsRegistrarTests.cs | 22 ++ .../DeleteRoutingSlipBuilderTests.cs | 199 ++++++++++++++ .../MassTransit/LifeCycleActivityTests.cs | 185 +++++++++++++ .../ProcessingSlipFaultConsumerTests.cs | 108 ++++++++ .../ProcessingStateMachineTests.cs | 207 ++++++++++++++ .../MassTransit/RoutingSlipBuilderTests.cs | 129 +++++++++ .../UpdatePicturesActivityTests.cs | 260 ++++++++++++++++++ 9 files changed, 1272 insertions(+) create mode 100644 backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs create mode 100644 backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs create mode 100644 backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs create mode 100644 backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs create mode 100644 backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs create mode 100644 backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs create mode 100644 backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs create mode 100644 backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs diff --git a/backend/Core/Musify.Infrastructure/Infrastructure.csproj b/backend/Core/Musify.Infrastructure/Infrastructure.csproj index a24d3fc..90108ae 100644 --- a/backend/Core/Musify.Infrastructure/Infrastructure.csproj +++ b/backend/Core/Musify.Infrastructure/Infrastructure.csproj @@ -42,6 +42,12 @@ + + + + + + diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs new file mode 100644 index 0000000..faa9eda --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs @@ -0,0 +1,156 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Musify.Api.DataTransferObjects.PlayLists; +using Musify.Api.Tests.TestSupport; +using Musify.Application.PlayLists.Responses; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Api.Tests.Endpoints; + +[Collection(ApiCollection.Name)] +public sealed class PlayListEndpointsTests(ApiTestFixture fixture) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private async Task CreatePlayListAsync(long ownerId, PlayListVisibility visibility, string? name = null) + { + var response = await fixture.CreateAuthenticatedClient(ownerId).PostAsJsonAsync( + "/playlists", + new CreatePlayListRequest(name ?? $"pl-{Guid.NewGuid():N}"[..20], null, null, visibility), + Ct); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(JsonOptions, Ct))!; + } + + [Fact] + public async Task GetPlayListById_PrivatePlayList_IsOnlyVisibleToItsOwner() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + + var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}", Ct); + var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}", Ct); + var asAnonymous = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct); + + Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, asAnonymous.StatusCode); + } + + [Fact] + public async Task GetPlayListById_PublicPlayList_IsVisibleToAnonymousUsers() + { + var owner = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + + var response = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetPlayListTracks_PrivatePlayList_IsHiddenFromStrangers() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + + var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}/tracks", Ct); + var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}/tracks", Ct); + + Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode); + } + + [Fact] + public async Task GetPlayListsByUserId_StrangersOnlySeePublicPlayLists() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var publicPlayList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + var privatePlayList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + + var asStranger = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(stranger), owner); + var asOwner = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(owner), owner); + + Assert.Equal([publicPlayList.Id], asStranger); + Assert.Contains(publicPlayList.Id, asOwner); + Assert.Contains(privatePlayList.Id, asOwner); + } + + [Fact] + public async Task UpdatePlayList_NotTheOwner_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + + var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync( + $"/playlists/{playList.Id}", new UpdatePlayListRequest("Hijacked", null, null), Ct); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task DeletePlayList_NotTheOwner_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + + var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/playlists/{playList.Id}", Ct); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task UpdatePlayList_Owner_RenamesIt() + { + var owner = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + + var response = await fixture.CreateAuthenticatedClient(owner).PutAsJsonAsync( + $"/playlists/{playList.Id}", new UpdatePlayListRequest("Renamed", null, null), Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, Ct); + Assert.Equal("Renamed", body!.Name); + } + + [Fact] + public async Task GetPlayListById_NotAGuid_ReturnsNotFound() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/playlists/not-a-guid", Ct); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task CreatePlayList_NameTooLong_ReturnsValidationProblem() + { + var owner = await fixture.SeedUserAsync(); + + var response = await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync( + "/playlists", new CreatePlayListRequest(new string('a', 51), null, null), Ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + private static async Task> ReadPlayListIdsAsync(HttpClient client, long userId) + { + var response = await client.GetAsync($"/playlists/users/{userId}", Ct); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var page = await response.Content.ReadFromJsonAsync>(JsonOptions, Ct); + return page!.Items.Select(playList => playList.Id).ToList(); + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs new file mode 100644 index 0000000..248e062 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs @@ -0,0 +1,22 @@ +using Musify.Infrastructure.Jobs; +using Xunit; + +namespace Musify.Infrastructure.Tests.Jobs; + +public sealed class RecurringJobsRegistrarTests +{ + [Theory] + [InlineData(1, "*/1 * * * *")] + [InlineData(59, "*/1 * * * *")] + [InlineData(60, "*/1 * * * *")] + [InlineData(61, "*/2 * * * *")] + [InlineData(300, "*/5 * * * *")] + [InlineData(3599, "0 */1 * * *")] + [InlineData(3600, "0 */1 * * *")] + [InlineData(7200, "0 */2 * * *")] + [InlineData(0, "*/1 * * * *")] + public void EveryCron_RoundsUpToWholeMinutes(int seconds, string expected) + { + Assert.Equal(expected, RecurringJobsRegistrar.EveryCron(seconds)); + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs new file mode 100644 index 0000000..a2b7af5 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs @@ -0,0 +1,199 @@ +using Musify.Application.Configuration; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.RoutingSlip; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; +using Musify.Infrastructure.Tests.TestSupport; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit; + +[Collection(InfrastructureCollection.Name)] +public sealed class DeleteRoutingSlipBuilderTests(InfrastructureTestFixture fixture) +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task BuildAlbum_WithProcessedPictures_RemovesEveryPictureBeforeDeletingTheRow() + { + var album = await SeedAlbumAsync(new EntityPictures + { + OriginalName = "original.webp", SmallName = "small.webp", MediumName = "medium.webp", LargeName = "large.webp" + }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkAlbumAsRemoving, + ActivityNames.RemoveAlbumOriginalPicture, + ActivityNames.RemoveAlbumSmallPicture, + ActivityNames.RemoveAlbumMediumPicture, + ActivityNames.RemoveAlbumLargePicture, + ActivityNames.DeleteAlbumFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildAlbum_WithPendingPicture_OnlyRemovesTheOriginal() + { + var album = await SeedAlbumAsync(EntityPictures.Pending("original.webp")); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ActivityNames.MarkAlbumAsRemoving, ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.DeleteAlbumFromDb], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildAlbum_MissingAlbum_OnlyMarksAndDeletes() + { + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildAlbumAsync(Guid.NewGuid(), 1, null, Ct)).Build(); + + Assert.Equal( + [ActivityNames.MarkAlbumAsRemoving, ActivityNames.DeleteAlbumFromDb], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildPlayList_WithoutPictures_OnlyMarksAndDeletes() + { + var playList = await SeedPlayListAsync(pictures: null); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ActivityNames.MarkPlayListAsRemoving, ActivityNames.DeletePlayListFromDb], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildPlayList_WithPictures_RemovesThemBetweenMarkAndDelete() + { + var playList = await SeedPlayListAsync(new EntityPictures { OriginalName = "original.webp", SmallName = "small.webp" }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkPlayListAsRemoving, + ActivityNames.RemovePlayListOriginalPicture, + ActivityNames.RemovePlayListSmallPicture, + ActivityNames.DeletePlayListFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildTrack_Unprocessed_NeverRemovesTheSharedPresetPictures() + { + var track = await SeedTrackAsync(new TrackPictures { OriginalName = "original.webp", SmallName = "preset.webp" }, new TrackAudio { OriginalName = "audio.mp3" }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkTrackAsRemoving, + ActivityNames.RemoveTrackOriginalPicture, + ActivityNames.RemoveTrackOriginalAudio, + ActivityNames.DeleteTrackFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildTrack_Processed_RemovesResizedPicturesAndProcessedAudio() + { + var track = await SeedTrackAsync( + new TrackPictures + { + OriginalName = "original.webp", SmallName = "s.webp", MediumName = "m.webp", LargeName = "l.webp", + ProcessingStatus = ProcessingStatus.Completed + }, + new TrackAudio { OriginalName = "audio.mp3", FolderName = "folder", TranscodeStatus = ProcessingStatus.Completed }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkTrackAsRemoving, + ActivityNames.RemoveTrackOriginalPicture, + ActivityNames.RemoveTrackSmallPicture, + ActivityNames.RemoveTrackMediumPicture, + ActivityNames.RemoveTrackLargePicture, + ActivityNames.RemoveTrackOriginalAudio, + ActivityNames.RemoveTrackProcessedAudio, + ActivityNames.DeleteTrackFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + private static DeleteRoutingSlipBuilder CreateBuilder(Musify.Application.Contracts.IDatabase database) => + new( + database, + new TrackConfiguration(), + new AlbumConfiguration(), + new PlayListConfiguration(), + new ApplicationStorageConfiguration { Bucket = "bucket" }); + + private async Task SeedUserAsync() + { + await using var database = fixture.CreateDatabase(); + var name = $"delete-{Guid.NewGuid():N}"[..30]; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, Ct); + await database.SaveChangesAsync(Ct); + return user; + } + + private async Task SeedAlbumAsync(EntityPictures? pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"album-{Guid.NewGuid():N}"; + var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.Albums.AddAsync(album, Ct); + await database.SaveChangesAsync(Ct); + return album; + } + + private async Task SeedPlayListAsync(EntityPictures? pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var name = $"pl-{Guid.NewGuid():N}"[..30]; + var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.PlayLists.AddAsync(playList, Ct); + await database.SaveChangesAsync(Ct); + return playList; + } + + private async Task SeedTrackAsync(TrackPictures pictures, TrackAudio audio) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"track-{Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 10, + OwnerUserId = user.Id, + Pictures = pictures, + Audio = audio + }; + await database.Tracks.AddAsync(track, Ct); + await database.SaveChangesAsync(Ct); + return track; + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs new file mode 100644 index 0000000..2d73fa0 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs @@ -0,0 +1,185 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Activities.LifeCycle; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.Tests.TestSupport; +using NSubstitute; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit; + +[Collection(InfrastructureCollection.Name)] +public sealed class LifeCycleActivityTests(InfrastructureTestFixture fixture) +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task MarkTrack_ExistingTrack_SetsTheStatus() + { + var track = await SeedTrackAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new MarkTrackLifeCycleActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new MarkLifeCycleArguments(track.Id, LifeCycleStatus.Removing)); + + await activity.Execute(context); + + context.Received(1).Completed(); + await using var verifier = fixture.CreateDatabase(); + var stored = await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct); + Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); + } + + [Fact] + public async Task MarkAlbum_ExistingAlbum_SetsTheStatus() + { + var album = await SeedAlbumAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new MarkAlbumLifeCycleActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(album.Id, LifeCycleStatus.Failed))); + + await using var verifier = fixture.CreateDatabase(); + Assert.Equal(LifeCycleStatus.Failed, (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).LifeCycleStatus); + } + + [Fact] + public async Task MarkPlayList_ExistingPlayList_SetsTheStatus() + { + var playList = await SeedPlayListAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new MarkPlayListLifeCycleActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(playList.Id, LifeCycleStatus.Removing))); + + await using var verifier = fixture.CreateDatabase(); + Assert.Equal(LifeCycleStatus.Removing, (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).LifeCycleStatus); + } + + [Fact] + public async Task Mark_MissingEntity_CompletesWithoutFailing() + { + await using var database = fixture.CreateDatabase(); + var activity = new MarkTrackLifeCycleActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new MarkLifeCycleArguments(Guid.NewGuid(), LifeCycleStatus.Failed)); + + await activity.Execute(context); + + context.Received(1).Completed(); + } + + [Fact] + public async Task DeleteTrack_ExistingTrack_RemovesTheRow() + { + var track = await SeedTrackAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new DeleteTrackActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new DeleteEntityArguments(track.Id)); + + await activity.Execute(context); + + context.Received(1).Completed(); + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.Tracks.AnyAsync(t => t.Id == track.Id, Ct)); + } + + [Fact] + public async Task DeleteAlbum_ExistingAlbum_RemovesTheRow() + { + var album = await SeedAlbumAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new DeleteAlbumActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(album.Id))); + + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.Albums.AnyAsync(a => a.Id == album.Id, Ct)); + } + + [Fact] + public async Task DeletePlayList_ExistingPlayList_RemovesTheRow() + { + var playList = await SeedPlayListAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new DeletePlayListActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(playList.Id))); + + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.PlayLists.AnyAsync(p => p.Id == playList.Id, Ct)); + } + + [Fact] + public async Task Delete_MissingEntity_CompletesWithoutFailing() + { + await using var database = fixture.CreateDatabase(); + var activity = new DeleteTrackActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new DeleteEntityArguments(Guid.NewGuid())); + + await activity.Execute(context); + + context.Received(1).Completed(); + } + + private static ExecuteContext ExecuteContextFor(TArguments arguments) + where TArguments : class + { + var context = Substitute.For>(); + context.Arguments.Returns(arguments); + context.CancellationToken.Returns(Ct); + return context; + } + + private async Task SeedUserAsync() + { + await using var database = fixture.CreateDatabase(); + var name = $"lifecycle-{Guid.NewGuid():N}"[..30]; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, Ct); + await database.SaveChangesAsync(Ct); + return user; + } + + private async Task SeedTrackAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"track-{Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 10, + OwnerUserId = user.Id, + Pictures = new TrackPictures(), + Audio = new TrackAudio() + }; + await database.Tracks.AddAsync(track, Ct); + await database.SaveChangesAsync(Ct); + return track; + } + + private async Task SeedAlbumAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"album-{Guid.NewGuid():N}"; + var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id }; + await database.Albums.AddAsync(album, Ct); + await database.SaveChangesAsync(Ct); + return album; + } + + private async Task SeedPlayListAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var name = $"pl-{Guid.NewGuid():N}"[..30]; + var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id }; + await database.PlayLists.AddAsync(playList, Ct); + await database.SaveChangesAsync(Ct); + return playList; + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs new file mode 100644 index 0000000..590b01c --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs @@ -0,0 +1,108 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using MassTransit.Testing; +using Microsoft.Extensions.DependencyInjection; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.Consumers; +using Musify.Infrastructure.MassTransit.RoutingSlip; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit; + +public sealed class ProcessingSlipFaultConsumerTests +{ + private static readonly Guid SubjectId = Guid.NewGuid(); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task Consume_TrackPicture_PublishesTrackPictureProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.TrackPicture, message => message.TrackId == SubjectId); + + [Fact] + public async Task Consume_TrackAudio_PublishesTrackAudioProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.TrackAudio, message => message.TrackId == SubjectId); + + [Fact] + public async Task Consume_PlayListPicture_PublishesPlayListPictureProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.PlayListPicture, message => message.PlayListId == SubjectId); + + [Fact] + public async Task Consume_AlbumPicture_PublishesAlbumPictureProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.AlbumPicture, message => message.AlbumId == SubjectId); + + [Fact] + public async Task Consume_TrackCreation_PublishesTrackProcessingFailedWithKeys() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.TrackCreation, + message => message is { Bucket: "bucket", PictureKey: "picture-key", AudioKey: "audio-key" } && message.TrackId == SubjectId); + + [Fact] + public async Task Consume_AlbumCreation_PublishesAlbumProcessingFailedWithKeys() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.AlbumCreation, + message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.AlbumId == SubjectId); + + [Fact] + public async Task Consume_PlayListCreation_PublishesPlayListProcessingFailedWithKeys() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.PlayListCreation, + message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.PlayListId == SubjectId); + + [Fact] + public async Task Consume_UnknownKind_PublishesNothing() + { + await using var provider = CreateProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + await harness.Bus.Publish(new { Variables = Variables("Something else") }, Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Consume_WithoutProcessKind_PublishesNothing() + { + await using var provider = CreateProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + await harness.Bus.Publish(new { Variables = new Dictionary() }, Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + } + + private static async Task AssertPublishedAsync(string processKind, Func matches) + where TEvent : class + { + await using var provider = CreateProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + await harness.Bus.Publish(new { Variables = Variables(processKind) }, Ct); + + Assert.True(await harness.Published.Any(published => matches(published.Context.Message), Ct)); + } + + private static Dictionary Variables(string processKind) => new() + { + [RoutingSlipVariableNames.Workflow.ProcessKind] = processKind, + [RoutingSlipVariableNames.Workflow.SubjectId] = SubjectId, + [RoutingSlipVariableNames.Workflow.Bucket] = "bucket", + [RoutingSlipVariableNames.Workflow.PictureKey] = "picture-key", + [RoutingSlipVariableNames.Workflow.AudioKey] = "audio-key", + }; + + private static ServiceProvider CreateProvider() => + new ServiceCollection() + .AddMassTransitTestHarness(bus => bus.AddConsumer()) + .BuildServiceProvider(true); +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs new file mode 100644 index 0000000..a86a199 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs @@ -0,0 +1,207 @@ +using MassTransit; +using MassTransit.Testing; +using Microsoft.Extensions.DependencyInjection; +using Musify.Application.Events; +using Musify.Application.Shared; +using Musify.Infrastructure.MassTransit.Sagas; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit; + +public sealed class ProcessingStateMachineTests +{ + private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3)); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Track + + [Fact] + public async Task Track_PictureAndAudioProcessed_CompletesTheSaga() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(TrackStarted(id), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new TrackPictureProcessed(id), Ct); + await harness.Bus.Publish(new TrackAudioProcessed(id), Ct); + + Assert.Null(await saga.NotExists(id)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Track_PictureFailed_PublishesProcessingFailedAndMovesToFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(TrackStarted(id), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new TrackPictureProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any( + published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture", AudioKey: "audio" } + && published.Context.Message.TrackId == id, Ct)); + } + + [Fact] + public async Task Track_AudioFailed_PublishesProcessingFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(TrackStarted(id), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Track_EventForMissingInstance_IsDiscarded() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new TrackPictureProcessed(id), Ct); + await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + Assert.False(await harness.Published.Any(Ct)); + } + + // Album + + [Fact] + public async Task Album_PictureProcessed_CompletesTheSaga() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new AlbumPictureProcessed(id), Ct); + + Assert.Null(await saga.NotExists(id)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Album_PictureFailed_PublishesProcessingFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new AlbumPictureProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any( + published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" } + && published.Context.Message.AlbumId == id, Ct)); + } + + [Fact] + public async Task Album_EventForMissingInstance_IsDiscarded() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + await harness.Bus.Publish(new AlbumPictureProcessingFailed(Guid.NewGuid()), Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + } + + // PlayList + + [Fact] + public async Task PlayList_PictureProcessed_CompletesTheSaga() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new PlayListPictureProcessed(id), Ct); + + Assert.Null(await saga.NotExists(id)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task PlayList_PictureFailed_PublishesProcessingFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new PlayListPictureProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any( + published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" } + && published.Context.Message.PlayListId == id, Ct)); + } + + [Fact] + public async Task PlayList_EventForMissingInstance_IsDiscarded() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + await harness.Bus.Publish(new PlayListPictureProcessingFailed(Guid.NewGuid()), Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + } + + private static CreateTrackResourcesEvent TrackStarted(Guid id) => + new(id, Guid.NewGuid(), Guid.NewGuid(), "bucket", "picture-source", "picture", "audio-source", "audio", "audio-folder", Sizes); + + private static async Task StartAsync(ServiceProvider provider) + { + var harness = provider.GetRequiredService(); + await harness.Start(); + return harness; + } + + private static ServiceProvider CreateProvider() + where TMachine : class, SagaStateMachine + where TState : class, SagaStateMachineInstance => + new ServiceCollection() + .AddMassTransitTestHarness(bus => bus.AddSagaStateMachine().InMemoryRepository()) + .BuildServiceProvider(true); +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs new file mode 100644 index 0000000..b35c96b --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs @@ -0,0 +1,129 @@ +using MassTransit.Courier.Contracts; +using Musify.Application.Events; +using Musify.Application.Shared; +using Musify.Infrastructure.Configuration; +using Musify.Infrastructure.MassTransit.Consumers; +using Musify.Infrastructure.MassTransit.RoutingSlip; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit; + +public sealed class RoutingSlipBuilderTests +{ + private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3)); + + private static readonly Guid SubjectId = Guid.NewGuid(); + private static readonly Guid IntentId = Guid.NewGuid(); + private static readonly Guid CorrelationId = Guid.NewGuid(); + + [Fact] + public void Create_AlwaysSubscribesTheCleanUpConsumerAndCarriesTheCorrelationId() + { + var slip = RoutingSlips.Create(CorrelationId).Build(); + + Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + var subscription = Assert.Single(slip.Subscriptions); + Assert.Equal(RoutingSlips.ConsumerUri(RoutingSlipCleanUpConsumer.QueueName), subscription.Address); + Assert.Equal(RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted, subscription.Events); + } + + [Fact] + public void Create_WithoutCorrelationId_UsesEmptyGuid() + { + var slip = RoutingSlips.Create(null).Build(); + + Assert.Equal(Guid.Empty, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + } + + [Fact] + public void TrackFaults_AddsSubjectProcessKindAndFaultSubscription() + { + var slip = RoutingSlips.Create(null).TrackFaults(SubjectId, "Kind").Build(); + + Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); + Assert.Equal("Kind", slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); + Assert.Contains(slip.Subscriptions, subscription => + subscription.Address == RoutingSlips.ConsumerUri(ProcessingSlipFaultConsumer.QueueName) + && subscription.Events == RoutingSlipEvents.Faulted); + } + + [Fact] + public void PictureSource_CreateAlbum_CopiesConsumesAndPublishesInOrder() + { + var message = new CreateAlbumResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumCreation, ActivityNames.PublishAlbumPictureProcessingEvent); + } + + [Fact] + public void PictureSource_UpdateAlbum_UsesTheAlbumPictureProcessKind() + { + var message = new UpdateAlbumPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumPicture, ActivityNames.PublishAlbumPictureProcessingEvent); + } + + [Fact] + public void PictureSource_CreatePlayList_CopiesConsumesAndPublishesInOrder() + { + var message = new CreatePlayListResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListCreation, ActivityNames.PublishPlayListPictureProcessingEvent); + } + + [Fact] + public void PictureSource_UpdatePlayList_UsesThePlayListPictureProcessKind() + { + var message = new UpdatePlayListPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, ActivityNames.PublishPlayListPictureProcessingEvent); + } + + [Fact] + public void AudioWorkflow_ChainsDownloadTranscodeTransferAndUpdate() + { + var configuration = new WorkerConfiguration(); + var message = new UpdateTrackAudioEvent(SubjectId, "source-bucket", "source-key", "destination-bucket", "destination-folder"); + + var slip = new AudioWorkflowRoutingSlipBuilder(configuration).Build(message, CorrelationId).Build(); + + Assert.Equal( + [ + ActivityNames.GenerateAudioWorkflowPaths, + ActivityNames.DownloadFile, + ActivityNames.TranscodeAudio, + ActivityNames.TransferFiles, + ActivityNames.UpdateTrackAudio, + ], + slip.Itinerary.Select(activity => activity.Name)); + Assert.Equal(RoutingSlipVariableNames.ProcessKinds.TrackAudio, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); + Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); + Assert.All(slip.Itinerary, activity => Assert.EndsWith("_execute", activity.Address.ToString())); + } + + private static void AssertPictureSourceSlip(RoutingSlip slip, string processKind, string publishStep) + { + Assert.Equal( + [ActivityNames.CopyPictureToFinal, ActivityNames.ConsumeUploadIntents, publishStep], + slip.Itinerary.Select(activity => activity.Name)); + + Assert.Equal(processKind, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); + Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); + Assert.Equal("bucket", slip.Variables[RoutingSlipVariableNames.Workflow.Bucket]); + Assert.Equal("final-key", slip.Variables[RoutingSlipVariableNames.Workflow.PictureKey]); + Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + + var copy = new Dictionary(slip.Itinerary[0].Arguments, StringComparer.OrdinalIgnoreCase); + Assert.Equal("source-key", copy["SourceKey"]?.ToString()); + Assert.Equal("final-key", copy["DestinationKey"]?.ToString()); + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs new file mode 100644 index 0000000..3378b39 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs @@ -0,0 +1,260 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using MassTransit.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Activities.Pictures; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; +using Musify.Infrastructure.MassTransit.RoutingSlip; +using Musify.Infrastructure.Tests.TestSupport; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit; + +/// Runs the picture-update activities inside real routing slips on the MassTransit test harness. +[Collection(InfrastructureCollection.Name)] +public sealed class UpdatePicturesActivityTests(InfrastructureTestFixture fixture) +{ + private const string SmallVariable = "Picture.Small"; + private const string MediumVariable = "Picture.Medium"; + private const string LargeVariable = "Picture.Large"; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task Album_Execute_StoresTheNamesAndPublishesTheEvent() + { + var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp"); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!; + Assert.Equal("new-original.webp", stored.OriginalName); + Assert.Equal("small.webp", stored.SmallName); + Assert.Equal("medium.webp", stored.MediumName); + Assert.Equal("large.webp", stored.LargeName); + Assert.True(await harness.Published.Any(published => published.Context.Message.AlbumId == album.Id, Ct)); + } + + [Fact] + public async Task PlayList_Execute_StoresTheNamesAndPublishesTheEvent() + { + var playList = await SeedPlayListAsync(EntityPictures.Pending("old.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdatePlayListPictureActivity.ExecuteEndpointName, playList.Id, "uploads/1/playlists/new.webp"); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).Pictures!; + Assert.Equal("new.webp", stored.OriginalName); + Assert.Equal("small.webp", stored.SmallName); + Assert.True(await harness.Published.Any(published => published.Context.Message.PlayListId == playList.Id, Ct)); + } + + [Fact] + public async Task Track_Execute_MarksThePicturesAsCompleted() + { + var track = await SeedTrackAsync(); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp"); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures; + Assert.Equal(ProcessingStatus.Completed, stored.ProcessingStatus); + Assert.Equal("large.webp", stored.LargeName); + Assert.True(await harness.Published.Any(published => published.Context.Message.TrackId == track.Id, Ct)); + } + + [Fact] + public async Task Execute_MissingEntity_FaultsTheSlip() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, Guid.NewGuid(), "uploads/1/albums/x.webp"); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + } + + [Fact] + public async Task Execute_MissingVariable_FaultsTheSlip() + { + var album = await SeedAlbumAsync(EntityPictures.Pending("old.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = RoutingSlips.Create(null).AddStep( + "Update", + UpdateAlbumPictureActivity.ExecuteEndpointName, + new UpdatePicturesArguments(album.Id, "key.webp", SmallVariable, MediumVariable, LargeVariable)); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + Assert.Equal("old.webp", (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName); + } + + [Fact] + public async Task Album_LaterStepFails_CompensationRestoresThePreviousNames() + { + var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp"); + slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments()); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await WaitUntilAsync(async () => + { + await using var poll = fixture.CreateDatabase(); + return (await poll.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName == "old-original.webp"; + }); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!; + Assert.Equal("old-original.webp", stored.OriginalName); + Assert.Null(stored.SmallName); + Assert.Null(stored.MediumName); + Assert.Null(stored.LargeName); + } + + [Fact] + public async Task Track_LaterStepFails_CompensationMarksThePicturesAsFailed() + { + var track = await SeedTrackAsync(); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp"); + slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments()); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await WaitUntilAsync(async () => + { + await using var poll = fixture.CreateDatabase(); + return (await poll.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures.ProcessingStatus == ProcessingStatus.Failed; + }); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures; + Assert.Equal(ProcessingStatus.Failed, stored.ProcessingStatus); + Assert.Equal("old.webp", stored.OriginalName); + } + + private static async Task WaitUntilAsync(Func> condition) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (!await condition() && DateTime.UtcNow < deadline) + await Task.Delay(100, Ct); + } + + private static RoutingSlipBuilder SlipFor(string endpointName, Guid subjectId, string originalKey) + { + var builder = RoutingSlips.Create(null).AddStep( + "Update", endpointName, new UpdatePicturesArguments(subjectId, originalKey, SmallVariable, MediumVariable, LargeVariable)); + builder.AddVariable(SmallVariable, "pictures/small.webp"); + builder.AddVariable(MediumVariable, "pictures/medium.webp"); + builder.AddVariable(LargeVariable, "pictures/large.webp"); + return builder; + } + + private ServiceProvider CreateProvider() => + new ServiceCollection() + .AddScoped(_ => fixture.CreateDatabase()) + .AddMassTransitTestHarness(bus => + { + bus.SetKebabCaseEndpointNameFormatter(); + bus.AddActivity(); + bus.AddActivity(); + bus.AddActivity(); + bus.AddExecuteActivity(); + }) + .BuildServiceProvider(true); + + private static async Task StartAsync(ServiceProvider provider) + { + var harness = provider.GetRequiredService(); + await harness.Start(); + return harness; + } + + private async Task SeedUserAsync() + { + await using var database = fixture.CreateDatabase(); + var name = $"pictures-{Guid.NewGuid():N}"[..30]; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, Ct); + await database.SaveChangesAsync(Ct); + return user; + } + + private async Task SeedAlbumAsync(EntityPictures pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"album-{Guid.NewGuid():N}"; + var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.Albums.AddAsync(album, Ct); + await database.SaveChangesAsync(Ct); + return album; + } + + private async Task SeedPlayListAsync(EntityPictures pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var name = $"pl-{Guid.NewGuid():N}"[..30]; + var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.PlayLists.AddAsync(playList, Ct); + await database.SaveChangesAsync(Ct); + return playList; + } + + private async Task SeedTrackAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"track-{Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 10, + OwnerUserId = user.Id, + Pictures = new TrackPictures { OriginalName = "old.webp" }, + Audio = new TrackAudio() + }; + await database.Tracks.AddAsync(track, Ct); + await database.SaveChangesAsync(Ct); + return track; + } + + public sealed record AlwaysFailsArguments; + + public sealed class AlwaysFailsActivity : IExecuteActivity + { + public const string ExecuteEndpointName = "always-fails"; + + public Task Execute(ExecuteContext context) => + throw new InvalidOperationException("Forced failure"); + } +} From 28a7c4a98c4ef9b40745cb05193d3cf7417db5f4 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 12:57:53 +0200 Subject: [PATCH 17/21] Enable more analyzers, drop log-and-rethrow in file activities and apply dotnet format Use invariant culture when formatting ids, put cancellation tokens last in endpoint handlers, remove the CS8618 pragmas and keep only the analyzer overrides that still apply. --- backend/.editorconfig | 27 ++++++---- .../Configuration/Pictures.cs | 5 +- .../Configuration/TrackConfiguration.cs | 3 +- .../Genres/GetAvailableGenres.cs | 2 +- .../Musify.Application/Genres/GetGenres.cs | 2 +- .../Mixes/GenerateMixesForUser.cs | 2 +- .../Musify.Application/Tracks/CreateTrack.cs | 2 +- .../Tracks/StartListening.cs | 3 +- .../Musify.Application/Users/GetUserById.cs | 2 +- backend/Core/Musify.Domain/Entities/Mix.cs | 3 +- .../Musify.Domain/ValueObjects/TrackAudio.cs | 1 - .../ValueObjects/TrackPictures.cs | 1 - .../Files/CopyFileInBucketActivity.cs | 30 ++++------- .../Files/DownloadFileFromBucketActivity.cs | 40 ++++++-------- .../Files/RemoveFileFromBucketActivity.cs | 7 --- .../Files/TransferFilesToBucketActivity.cs | 26 +++------ .../Files/UploadFileToBucketActivity.cs | 24 +++------ .../Pictures/ResizePictureActivity.cs | 54 ++++++++----------- .../Consumers/RoutingSlipCleanUpConsumer.cs | 8 +-- ...TransitDependencyInjection.Registration.cs | 2 +- .../MassTransitDependencyInjection.cs | 2 +- .../Builders/DeleteRoutingSlipBuilder.cs | 2 +- .../OpenTelemetryDependencyInjection.cs | 2 +- .../DatabaseDependencyInjection.cs | 2 +- .../Services/StreamTicketService.cs | 5 +- .../Authentication/JwtBearerEventsHandler.cs | 18 +++---- .../Musify.Api/Endpoints/AlbumEndpoints.cs | 12 ++--- .../Musify.Api/Endpoints/LikeEndpoints.cs | 4 +- .../Musify.Api/Endpoints/PlayListEndpoints.cs | 8 +-- .../Musify.Api/Endpoints/TrackEndpoints.cs | 8 +-- .../Musify.Api/Endpoints/UserEndpoints.cs | 12 ++--- .../Endpoints/AlbumEndpointsTests.cs | 2 +- .../TestSupport/ApiTestFixture.cs | 3 +- .../GetAvailableGenresQueryHandlerTests.cs | 2 +- .../TestSupport/HandlerTestBase.cs | 6 ++- .../DeleteRoutingSlipBuilderTests.cs | 10 +++- .../TestSupport/InfrastructureTestFixture.cs | 4 +- .../Authentication/TicketValidatorTests.cs | 3 +- .../TicketValidationMiddlewareTests.cs | 3 +- 39 files changed, 160 insertions(+), 192 deletions(-) diff --git a/backend/.editorconfig b/backend/.editorconfig index 8af7303..bcc34a2 100644 --- a/backend/.editorconfig +++ b/backend/.editorconfig @@ -41,6 +41,13 @@ 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_static_readonly.symbols = private_static_readonly +dotnet_naming_rule.private_static_readonly.style = pascal_case +dotnet_naming_rule.private_static_readonly.severity = warning +dotnet_naming_symbols.private_static_readonly.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly.applicable_accessibilities = private +dotnet_naming_symbols.private_static_readonly.required_modifiers = static, readonly + dotnet_naming_rule.private_constants.symbols = private_constants dotnet_naming_rule.private_constants.style = pascal_case dotnet_naming_rule.private_constants.severity = warning @@ -48,18 +55,16 @@ 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. +# CA1848/CA1873 would turn every log call into a [LoggerMessage] source-generated method; CA1716 flags the +# EF-style IDatabase.Set() and the Musify.Application.Shared namespace, whose names are deliberate. 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 +# The routing slip builders are stateless services resolved from DI; they stay instance members. 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 + +# Test names follow Method_Scenario_Expected. +[Tests/**.cs] +dotnet_diagnostic.CA1707.severity = none +# xUnit collection fixtures are conventionally named ...Collection. +dotnet_diagnostic.CA1711.severity = none diff --git a/backend/Core/Musify.Application/Configuration/Pictures.cs b/backend/Core/Musify.Application/Configuration/Pictures.cs index bed36ff..5e71035 100644 --- a/backend/Core/Musify.Application/Configuration/Pictures.cs +++ b/backend/Core/Musify.Application/Configuration/Pictures.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Globalization; using Musify.Application.Shared; namespace Musify.Application.Configuration; @@ -41,10 +42,10 @@ public class PictureRoutes public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName); public string BuildOriginalPicturePath(long userId, string pictureName) => - StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolder, OriginalPicturesFolder, pictureName); + StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalPicturesFolder, pictureName); public string BuildTempPath(string tempRootPrefix, long userId, string objectName) => - StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName); + StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, objectName); } public class PictureSizes diff --git a/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs b/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs index b391286..80b8df3 100644 --- a/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Globalization; using Musify.Application.Shared; namespace Musify.Application.Configuration; @@ -48,7 +49,7 @@ public class TrackRoutes : PictureRoutes public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture); public string BuildOriginalAudioPath(long userId, string audioName) => - StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolder, OriginalAudiosFolder, audioName); + StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalAudiosFolder, audioName); public string BuildProcessedAudioPath(string folderName) => StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName); diff --git a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs index cffdd5f..694c9cd 100644 --- a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs @@ -1,6 +1,6 @@ using Mediator; -using Musify.Domain.ValueObjects; using Musify.Application.Genres.Responses; +using Musify.Domain.ValueObjects; namespace Musify.Application.Genres; diff --git a/backend/Core/Musify.Application/Genres/GetGenres.cs b/backend/Core/Musify.Application/Genres/GetGenres.cs index 396db60..6ad2465 100644 --- a/backend/Core/Musify.Application/Genres/GetGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetGenres.cs @@ -1,8 +1,8 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; using Musify.Application.Genres.Responses; +using Musify.Domain.ValueObjects; namespace Musify.Application.Genres; diff --git a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs index e5c6a26..0bf792f 100644 --- a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs +++ b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -79,7 +79,7 @@ public class GenerateMixesForUserCommandHandler( private static List Shuffle(IEnumerable trackIds) => [.. trackIds.OrderBy(_ => Guid.NewGuid())]; - private static List Interleave(IReadOnlyList first, IReadOnlyList second) + private static List Interleave(List first, List second) { var merged = new List(first.Count + second.Count); for (var index = 0; index < Math.Max(first.Count, second.Count); index++) diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index 6c42b75..52ec80d 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -4,9 +4,9 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; -using Musify.Application.Shared; using Musify.Application.Events; using Musify.Application.Services; +using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; diff --git a/backend/Core/Musify.Application/Tracks/StartListening.cs b/backend/Core/Musify.Application/Tracks/StartListening.cs index d580698..ef3aeb1 100644 --- a/backend/Core/Musify.Application/Tracks/StartListening.cs +++ b/backend/Core/Musify.Application/Tracks/StartListening.cs @@ -1,3 +1,4 @@ +using System.Globalization; using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; @@ -48,7 +49,7 @@ public class StartListeningCommandHandler( listenId = listen.Id; } - logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString() ?? "(anonymous)"); + logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString(CultureInfo.InvariantCulture) ?? "(anonymous)"); return streamIssuer.Issue(track.Audio.FolderName, request.UserId, listenId); } diff --git a/backend/Core/Musify.Application/Users/GetUserById.cs b/backend/Core/Musify.Application/Users/GetUserById.cs index 3069355..0d6eab3 100644 --- a/backend/Core/Musify.Application/Users/GetUserById.cs +++ b/backend/Core/Musify.Application/Users/GetUserById.cs @@ -2,8 +2,8 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; -using Musify.Application.Users.Responses; using Musify.Application.Shared; +using Musify.Application.Users.Responses; namespace Musify.Application.Users; diff --git a/backend/Core/Musify.Domain/Entities/Mix.cs b/backend/Core/Musify.Domain/Entities/Mix.cs index 52df127..4951abe 100644 --- a/backend/Core/Musify.Domain/Entities/Mix.cs +++ b/backend/Core/Musify.Domain/Entities/Mix.cs @@ -1,8 +1,7 @@ using System.ComponentModel.DataAnnotations; -using Musify.Domain.ValueObjects; using System.ComponentModel.DataAnnotations.Schema; - using Musify.Domain.Abstractions; +using Musify.Domain.ValueObjects; namespace Musify.Domain.Entities; diff --git a/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs b/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs index a558d13..8ed8584 100644 --- a/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs +++ b/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs @@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis; namespace Musify.Domain.ValueObjects; -#pragma warning disable CS8618 public class TrackAudio { public string? OriginalName { get; set; } diff --git a/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs b/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs index 511f658..05a57d8 100644 --- a/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs +++ b/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs @@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis; namespace Musify.Domain.ValueObjects; -#pragma warning disable CS8618 public class TrackPictures { public string? OriginalName { get; set; } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs index b6e4861..5662355 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs @@ -15,28 +15,16 @@ internal class CopyFileInBucketActivity( public async Task Execute(ExecuteContext executeContext) { - try - { - await storageService.CopyFileAsync( - executeContext.Arguments.SourceBucket, - executeContext.Arguments.SourceKey, - executeContext.Arguments.DestinationBucket, - executeContext.Arguments.DestinationKey, - executeContext.CancellationToken); + await storageService.CopyFileAsync( + executeContext.Arguments.SourceBucket, + executeContext.Arguments.SourceKey, + executeContext.Arguments.DestinationBucket, + executeContext.Arguments.DestinationKey, + executeContext.CancellationToken); - return executeContext.Completed(new CopyFileInBucketLog( - executeContext.Arguments.DestinationBucket, - executeContext.Arguments.DestinationKey)); - } - catch (Exception exception) - { - 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) diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs index 6b246a5..3faab94 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs @@ -18,33 +18,25 @@ internal class DownloadFileFromBucketActivity( var destinationPath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); ArgumentNullException.ThrowIfNull(destinationPath); - try - { - using var fileStream = await storageService.GetFileAsync( - executeContext.Arguments.Bucket, - executeContext.Arguments.Key, - executeContext.CancellationToken) - ?? throw new FileNotFoundException($"Object {executeContext.Arguments.Bucket}/{executeContext.Arguments.Key} was not found."); + using var fileStream = await storageService.GetFileAsync( + executeContext.Arguments.Bucket, + executeContext.Arguments.Key, + executeContext.CancellationToken) + ?? throw new FileNotFoundException($"Object {executeContext.Arguments.Bucket}/{executeContext.Arguments.Key} was not found."); - var destinationDirectory = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrWhiteSpace(destinationDirectory)) - Directory.CreateDirectory(destinationDirectory); + 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) diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs index 73c737b..e5fce5d 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs @@ -34,12 +34,5 @@ internal class RemoveFileFromBucketActivity( 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; - } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs index eb397d8..7b5f310 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs @@ -20,25 +20,15 @@ internal class TransferFilesToBucketActivity( var destinationKey = executeContext.Arguments.DestinationKey; ArgumentNullException.ThrowIfNull(destinationKey); - try - { - var uploadedKeys = await storageService.TransferFilesAsync( - folderPath, - executeContext.Arguments.DestinationBucket, - destinationKey, - executeContext.CancellationToken); + var uploadedKeys = await storageService.TransferFilesAsync( + folderPath, + executeContext.Arguments.DestinationBucket, + destinationKey, + executeContext.CancellationToken); - return executeContext.Completed(new TransferFilesToBucketLog( - executeContext.Arguments.DestinationBucket, - uploadedKeys.ToArray())); - } - catch (Exception exception) - { - 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) diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs index c9adc6f..f66d1db 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs @@ -21,22 +21,14 @@ internal class UploadFileToBucketActivity(IStorageService storageService, .Where(static s => !string.IsNullOrWhiteSpace(s)) .Select(static s => s.Trim().Trim('/', '\\'))); - try - { - await storageService.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 storageService.UploadFileAsync( + sourceFilePath, + executeContext.Arguments.DestinationBucket, + destinationKey, + executeContext.CancellationToken); + return executeContext.CompletedWithVariables(new UploadFileToBucketLog( + executeContext.Arguments.DestinationBucket, + destinationKey)); } public async Task Compensate(CompensateContext compensateContext) diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs index f3614ba..94c7758 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs @@ -20,39 +20,31 @@ internal class ResizePictureActivity( var destinationFilePath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); ArgumentNullException.ThrowIfNull(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) diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs index 870e010..0239bd4 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs @@ -12,11 +12,11 @@ public class RoutingSlipCleanUpConsumer( { public const string QueueName = "routing-slip-clean-up"; - public Task Consume(ConsumeContext consumeContext) - => DeleteFolder(consumeContext.Message.Variables); + public Task Consume(ConsumeContext context) + => DeleteFolder(context.Message.Variables); - public Task Consume(ConsumeContext consumeContext) - => DeleteFolder(consumeContext.Message.Variables); + public Task Consume(ConsumeContext context) + => DeleteFolder(context.Message.Variables); private Task DeleteFolder(IDictionary variables) { diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs index b039d5a..0053b0d 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Musify.Infrastructure.MassTransit.Activities.Albums; using Musify.Infrastructure.MassTransit.Activities.Audio; using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.LifeCycle; using Musify.Infrastructure.MassTransit.Activities.Pictures; using Musify.Infrastructure.MassTransit.Activities.PlayLists; using Musify.Infrastructure.MassTransit.Activities.Tracks; @@ -11,7 +12,6 @@ using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Consumers; using Musify.Infrastructure.MassTransit.Logs; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -using Musify.Infrastructure.MassTransit.Activities.LifeCycle; namespace Musify.Infrastructure.MassTransit; diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs index f5ec69a..3ddf79f 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs @@ -1,11 +1,11 @@ using MassTransit; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.MassTransit.Sagas; using Musify.Infrastructure.Persistence; -using Musify.Application.Configuration; namespace Musify.Infrastructure.MassTransit; diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs index b13d252..694c7ff 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs @@ -3,8 +3,8 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Domain.ValueObjects; -using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Activities.LifeCycle; +using Musify.Infrastructure.MassTransit.Arguments; namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; diff --git a/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs index 83a513f..2178791 100644 --- a/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs @@ -1,12 +1,12 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Musify.Application.Configuration; using Musify.Infrastructure.Configuration; using OpenTelemetry.Exporter; using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; -using Musify.Application.Configuration; namespace Musify.Infrastructure.Observability; diff --git a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs index c21b070..9ba10fe 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs @@ -1,8 +1,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; -using Musify.Application.Configuration; namespace Musify.Infrastructure.Persistence; diff --git a/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs b/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs index f5c8d17..f52e92a 100644 --- a/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Cryptography; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; @@ -30,9 +31,9 @@ public sealed class StreamTicketService : IStreamTicketService, IDisposable var claims = new Dictionary { ["prefix"] = keyPrefix }; if (userId != null) - claims["sub"] = userId.Value.ToString(); + claims["sub"] = userId.Value.ToString(CultureInfo.InvariantCulture); if (maxBytes != null) - claims["maxBytes"] = maxBytes.Value.ToString(); + claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); var descriptor = new SecurityTokenDescriptor { diff --git a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs index 6f65216..2a705c9 100644 --- a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs +++ b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs @@ -26,9 +26,9 @@ public sealed class JwtBearerEventsHandler( [property: JsonPropertyName("family_name")] string? FamilyName, [property: JsonPropertyName("picture")] string? Picture); - public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext) + public override async Task TokenValidated(TokenValidatedContext context) { - var principal = tokenValidatedContext.Principal!; + var principal = context.Principal!; var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); if (!long.TryParse(rawId, out var userId)) @@ -41,7 +41,7 @@ public sealed class JwtBearerEventsHandler( if (cache.TryGetValue(cacheKey, out _)) return; - var userInfo = await FetchUserInfoAsync(tokenValidatedContext, tokenValidatedContext.HttpContext.RequestAborted); + var userInfo = await FetchUserInfoAsync(context, context.HttpContext.RequestAborted); var username = userInfo?.Name ?? userInfo?.PreferredUsername @@ -65,7 +65,7 @@ public sealed class JwtBearerEventsHandler( { await mediator.Send( new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl), - tokenValidatedContext.HttpContext.RequestAborted); + context.HttpContext.RequestAborted); cache.Set(cacheKey, true, UserSyncCacheTtl); } @@ -76,9 +76,9 @@ public sealed class JwtBearerEventsHandler( } private async Task FetchUserInfoAsync( - TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken) + TokenValidatedContext context, CancellationToken cancellationToken) { - var authorizationHeader = tokenValidatedContext.HttpContext.Request.Headers.Authorization.ToString(); + var authorizationHeader = context.HttpContext.Request.Headers.Authorization.ToString(); if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) return null; @@ -88,7 +88,7 @@ public sealed class JwtBearerEventsHandler( try { - var userInfoEndpoint = await ResolveUserInfoEndpointAsync(tokenValidatedContext, cancellationToken); + var userInfoEndpoint = await ResolveUserInfoEndpointAsync(context, cancellationToken); var client = httpClientFactory.CreateClient(); using var request = new HttpRequestMessage(HttpMethod.Get, userInfoEndpoint) @@ -113,9 +113,9 @@ public sealed class JwtBearerEventsHandler( } private async Task ResolveUserInfoEndpointAsync( - TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken) + TokenValidatedContext context, CancellationToken cancellationToken) { - var configurationManager = tokenValidatedContext.Options.ConfigurationManager; + var configurationManager = context.Options.ConfigurationManager; if (configurationManager != null) { var configuration = await configurationManager.GetConfigurationAsync(cancellationToken); diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index 1e56399..2d7a4df 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -129,9 +129,9 @@ public static class AlbumEndpoints private static async Task GetAlbums( IMediator mediator, - CancellationToken cancellationToken, string? title, - [AsParameters] PageQuery page) + [AsParameters] PageQuery page, + CancellationToken cancellationToken) { var result = await mediator.Send(new GetAlbumsQuery(title, page.PageNumber, page.PageSize), cancellationToken); return Results.Ok(result); @@ -149,8 +149,8 @@ public static class AlbumEndpoints private static async Task GetAlbumsByUserId( IMediator mediator, long userId, - CancellationToken cancellationToken, - [AsParameters] PageQuery page) + [AsParameters] PageQuery page, + CancellationToken cancellationToken) { var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); @@ -177,8 +177,8 @@ public static class AlbumEndpoints private static async Task GetAlbumTracks( IMediator mediator, Guid id, - CancellationToken cancellationToken, - [AsParameters] PageQuery page) + [AsParameters] PageQuery page, + CancellationToken cancellationToken) { var result = await mediator.Send(new GetAlbumTracksQuery(id, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs index 6d1ec73..af30182 100644 --- a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs @@ -38,8 +38,8 @@ public static class LikeEndpoints private static async Task GetLikedTracks( IMediator mediator, CurrentUser currentUser, - CancellationToken cancellationToken, - [AsParameters] PageQuery page) + [AsParameters] PageQuery page, + CancellationToken cancellationToken) { var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken); return Results.Ok(result); diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index 74d51ac..d21d8b4 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -125,9 +125,9 @@ public static class PlayListEndpoints IMediator mediator, CurrentUser currentUser, long userId, - CancellationToken cancellationToken, string? name, - [AsParameters] PageQuery page) + [AsParameters] PageQuery page, + CancellationToken cancellationToken) { var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); return Results.Ok(result); @@ -137,8 +137,8 @@ public static class PlayListEndpoints IMediator mediator, CurrentUser currentUser, Guid id, - CancellationToken cancellationToken, - [AsParameters] PageQuery page) + [AsParameters] PageQuery page, + CancellationToken cancellationToken) { var result = await mediator.Send(new GetPlayListTracksQuery(id, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index 3db1c4e..c8ee6e1 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -97,10 +97,10 @@ public static class TrackEndpoints private static async Task GetTracks( IMediator mediator, - CancellationToken cancellationToken, string? name, GenreParameter? genre, - [AsParameters] PageQuery page) + [AsParameters] PageQuery page, + CancellationToken cancellationToken) { var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, genre?.Value), cancellationToken); return Results.Ok(result); @@ -152,9 +152,9 @@ public static class TrackEndpoints private static async Task GetTracksByUserId( IMediator mediator, long userId, - CancellationToken cancellationToken, string? name, - [AsParameters] PageQuery page) + [AsParameters] PageQuery page, + CancellationToken cancellationToken) { var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs index 5eaac41..1c6815e 100644 --- a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -100,9 +100,9 @@ public static class UserEndpoints private static async Task GetUsers( IMediator mediator, CurrentUser currentUser, - CancellationToken cancellationToken, string? usernameSearch, - [AsParameters] PageQuery page) + [AsParameters] PageQuery page, + CancellationToken cancellationToken) { var result = await mediator.Send(new GetUsersQuery(page.PageNumber, page.PageSize, usernameSearch, currentUser.Id), cancellationToken); return Results.Ok(result); @@ -162,8 +162,8 @@ public static class UserEndpoints IMediator mediator, CurrentUser currentUser, long id, - CancellationToken cancellationToken, - [AsParameters] PageQuery page) + [AsParameters] PageQuery page, + CancellationToken cancellationToken) { var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); @@ -173,8 +173,8 @@ public static class UserEndpoints IMediator mediator, CurrentUser currentUser, long id, - CancellationToken cancellationToken, - [AsParameters] PageQuery page) + [AsParameters] PageQuery page, + CancellationToken cancellationToken) { var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index 4a7f5c9..0a79070 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -4,8 +4,8 @@ using Musify.Api.DataTransferObjects; using Musify.Api.DataTransferObjects.Albums; using Musify.Api.Tests.TestSupport; using Musify.Application.Albums.Responses; -using Musify.Application.Pictures.Responses; using Musify.Application.Contracts; +using Musify.Application.Pictures.Responses; using NSubstitute; using Xunit; diff --git a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs index e2a5961..83751b4 100644 --- a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs +++ b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -64,7 +65,7 @@ public sealed class ApiTestFixture : WebApplicationFactory, IAsyncLifet public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user") { var client = CreateClient(); - client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString()); + client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString(CultureInfo.InvariantCulture)); client.DefaultRequestHeaders.Add("X-Test-User-Name", userName); return client; } diff --git a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs index 29fd50d..9e63b58 100644 --- a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs @@ -1,7 +1,7 @@ using Musify.Application.Genres; +using Musify.Application.Genres.Responses; using Musify.Domain.ValueObjects; using Xunit; -using Musify.Application.Genres.Responses; namespace Musify.Application.Tests.Genres; diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs index 42b925f..544efda 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs @@ -12,7 +12,11 @@ public abstract class HandlerTestBase : IAsyncLifetime public async ValueTask InitializeAsync() => database = await TestDatabase.CreateAsync(); - public ValueTask DisposeAsync() => database.DisposeAsync(); + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + return database.DisposeAsync(); + } protected static ILogger NoOpLogger() => NullLogger.Instance; diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs index a2b7af5..d1a0705 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs @@ -18,7 +18,10 @@ public sealed class DeleteRoutingSlipBuilderTests(InfrastructureTestFixture fixt { var album = await SeedAlbumAsync(new EntityPictures { - OriginalName = "original.webp", SmallName = "small.webp", MediumName = "medium.webp", LargeName = "large.webp" + OriginalName = "original.webp", + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp" }); await using var database = fixture.CreateDatabase(); @@ -116,7 +119,10 @@ public sealed class DeleteRoutingSlipBuilderTests(InfrastructureTestFixture fixt var track = await SeedTrackAsync( new TrackPictures { - OriginalName = "original.webp", SmallName = "s.webp", MediumName = "m.webp", LargeName = "l.webp", + OriginalName = "original.webp", + SmallName = "s.webp", + MediumName = "m.webp", + LargeName = "l.webp", ProcessingStatus = ProcessingStatus.Completed }, new TrackAudio { OriginalName = "audio.mp3", FolderName = "folder", TranscodeStatus = ProcessingStatus.Completed }); diff --git a/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs b/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs index a504aa2..7ccc578 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs @@ -18,6 +18,8 @@ public sealed class InfrastructureTestFixture : IAsyncLifetime public const string S3SecretKey = "musify-test-secret"; public const string S3Bucket = "musify-test-bucket"; + private static readonly string[] S3Actions = ["Admin", "Read", "Write"]; + private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") .WithDatabase("musify_test") .WithUsername("postgres") @@ -83,7 +85,7 @@ public sealed class InfrastructureTestFixture : IAsyncLifetime { name = "musify-test", credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } }, - actions = new[] { "Admin", "Read", "Write" } + actions = S3Actions } } }); diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs index 801e9b6..d96b479 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Cryptography; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; @@ -36,7 +37,7 @@ public sealed class TicketValidatorTests : IDisposable { var claims = new Dictionary { ["prefix"] = prefix }; if (maxBytes != null) - claims["maxBytes"] = maxBytes.Value.ToString(); + claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); var descriptor = new SecurityTokenDescriptor { diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs index a4735f0..96fe153 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Cryptography; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; @@ -39,7 +40,7 @@ public sealed class TicketValidationMiddlewareTests : IDisposable { var claims = new Dictionary { ["prefix"] = prefix }; if (maxBytes != null) - claims["maxBytes"] = maxBytes.Value.ToString(); + claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor { From 7f51145915a739ab0f14e5f504bc7e3c4f5abbc6 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 13:01:32 +0200 Subject: [PATCH 18/21] Update docs for the refactored backend and add backend conventions guide --- backend/CLAUDE.md | 58 ++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 25 ++++++++--------- docs/authentication.md | 2 +- docs/development.md | 7 ++--- docs/media-processing.md | 22 +++++++++------ docs/projects.md | 29 +++++++++++--------- docs/storage.md | 14 +++++----- docs/streaming.md | 6 ++--- 8 files changed, 117 insertions(+), 46 deletions(-) create mode 100644 backend/CLAUDE.md diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md new file mode 100644 index 0000000..a8545e7 --- /dev/null +++ b/backend/CLAUDE.md @@ -0,0 +1,58 @@ +# Musify: backend + +.NET 10 solution (`Musify.slnx`): `Core/` (Domain, Application, Infrastructure), `Hosts/` (Api, Worker, StreamingGateway) and `Tests/`. Architecture and flows are in `../docs/`. + +## Commands + +Run from `backend/`. + +```sh +dotnet build Musify.slnx # warnings are errors, including unused usings (IDE0005) +dotnet test Musify.slnx # Infrastructure and Api tests need Docker (Testcontainers) +dotnet format Musify.slnx --verify-no-changes +``` + +- Read the totals of `dotnet test` (`total:`, `succeeded:`) before trusting it; don't chain it with `&&` into a commit. +- EF migrations: `dotnet ef migrations add --project Core/Musify.Infrastructure --startup-project Core/Musify.Infrastructure` (set `Database__ConnectionString` to any value). A model change needs a migration; the entity model lives in `ModelConfiguration.Apply`, which the in-memory test database reuses. +- An HTTP contract change (routes, DTOs, responses) needs the web-player schema regenerated (`web-player/src/lib/api/schema.d.ts`, from `/openapi/v1.json`) and `npm run check` clean. +- A MassTransit message change (events, routing slip arguments, process kinds) means the queues must be drained before deploying; note it in the commit. + +## Style + +- File-scoped namespaces, usings outside the namespace and sorted (`.editorconfig` enforces it). +- Private fields camelCase without underscore, constants and `static readonly` fields PascalCase, explicit accessibility. +- `ArgumentNullException.ThrowIfNull(x)` without `nameof`. +- Use `PlayList` (not `Playlist`) in code; configuration POCOs end in `Configuration`; injected services are named after their type (`storageService`). +- User-facing strings are in English (the web-player owns translation). +- Trim stored names; normalized columns go through `TextNormalizer.Normalize`. +- Format ids and numbers with `CultureInfo.InvariantCulture`; put `CancellationToken` last. + +## Application layer + +- One file per use case, `/.cs`, holding the record and its handler; responses go in `Responses/`. +- Errors come from `AppErrors` (`NotFound`, `Forbidden` = 403 for "not the owner", `Conflict`). `Error.Unauthorized` (401) only means "not signed in". The success value is `Result.Success`; the API maps it to 204. +- Handlers that cannot fail return plain types. Lists are paginated through `PageRequest` / `ToPaginatedAsync`, ordered with an `Id` tiebreak, and public reads return only `LifeCycleStatus.Active` rows. +- Owned-entity operations go through `FindOwnedAsync` (non-active entities are not found). +- No try/catch around `SaveChangesAsync` or `PublishAsync` in handlers: the outbox makes publish + save atomic. Infrastructure either handles an error or lets it propagate; never log and rethrow. +- List query records keep `int PageNumber, int PageSize`; the API binds them through `PageQuery` and validates them with `PageQueryValidator`. + +## API layer + +- Routes: `{id:guid}` for the resource, `{trackId:guid}` for sub-resources, `{id:long}` / `{userId:long}` for user ids. +- Every endpoint returns `IResult` (`Results.Ok` when the handler cannot fail, `ToHttpResult()` otherwise). +- Validation limits live in `Validators/Limits.cs`; the database column length wins when a limit disagrees with the web-player. Upload allow-lists are in `Validators/Uploads.cs`. +- `.WithSummary(...)` is sentence case without a trailing period. + +## Infrastructure and hosts + +- MassTransit routing slips are built from `RoutingSlips.Create` / `AddStep` / `TrackFaults`; activities for the three owner types share generic bases (`MarkLifeCycleActivity`, `DeleteEntityActivity`, `UpdatePicturesActivity`). +- Recurring work is Hangfire (`RecurringJobsRegistrar`); intervals are in seconds and rounded up to whole minutes. +- The Worker calls `AddApplication` and validates its service container on build, so any handler dependency must be registered there. Shared config sections in its `appsettings.json` mirror the API's. +- The StreamingGateway references neither Application nor Infrastructure and keeps its own options. + +## Tests + +- xUnit v3; pass `TestContext.Current.CancellationToken` to methods that take a token (analyzer xUnit1051). +- Test names are `Method_Scenario_Expected`. +- Application tests use the in-memory SQLite `TestDatabase`; Infrastructure and Api tests use Testcontainers (Postgres, SeaweedFS). MassTransit sagas and consumers are tested on the MassTransit test harness. +- `Infrastructure` exposes its internals to `Musify.Infrastructure.Tests`. diff --git a/docs/architecture.md b/docs/architecture.md index 167b6ed..6abaac8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,7 +3,7 @@ ## Principles - **Clean architecture / layers** in `backend/`: the domain and application layers don't depend on infrastructure; infrastructure implements their interfaces (`Application/Contracts`). -- **CQRS**: every operation is a `Command` or `Query` with its own handler (Mediator). Handlers return `Ardalis.Result` (Ok/NotFound/Unauthorized/Conflict/Invalid…), which the API translates into HTTP status codes. +- **CQRS**: every operation is a `Command` or `Query` with its own handler (Mediator). Handlers that can fail return `ErrorOr` (built with `AppErrors`: NotFound, Forbidden, Conflict, Validation; `Unauthorized` only for "not signed in"), which the API translates into HTTP status codes and ProblemDetails with `ToHttpResult()`. - **Heavy bytes never touch the API**: uploads use presigned URLs (client → S3) and playback goes through a dedicated reverse proxy (gateway → S3). The API only moves metadata and issues permissions. - **Async processing**: creating a track doesn't transcode it on the spot. It publishes an event, and a worker does the heavy lifting, with compensation (MassTransit sagas/routing slips). @@ -11,11 +11,11 @@ | Component | Responsibility | Why it's separate | |---|---|---| -| **Musify.Api** | REST API (users, tracks, playlists), authorizes requests, issues presigned URLs and stream tickets | A thin entry point, it doesn't process media or serve bytes | +| **Musify.Api** | REST API (users, tracks, albums, playlists, likes, mixes, genres), authorizes requests, issues presigned URLs and stream tickets | A thin entry point, it doesn't process media or serve bytes | | **Domain/Application/Infrastructure** | Domain + use cases + shared infrastructure (EF, S3, MassTransit, ffmpeg) | Logic shared between the API and the Worker | | **StreamingGateway** | Reverse proxy (YARP) that validates the ticket and serves the audio (`.m4a`) from SeaweedFS | Scales with byte traffic independently of the API, and keeps SeaweedFS private behind it | -| **Worker** | Consumes MassTransit events: transcodes audio to `.m4a` and generates thumbnails | Keeps CPU/IO-heavy work off the HTTP request path | -| **PostgreSQL** *(shared, runs in the separate `Infrastructure` repo)* | Metadata (users, tracks, playlists, upload intents) | n/a | +| **Worker** | Consumes MassTransit events (transcodes audio to `.m4a`, generates thumbnails, deletes entities) and runs the recurring Hangfire jobs | Keeps CPU/IO-heavy work off the HTTP request path | +| **PostgreSQL** *(shared, runs in the separate `Infrastructure` repo)* | Metadata (users, tracks, albums, playlists, upload intents, listening history) and the Hangfire job store | n/a | | **RabbitMQ** *(shared, runs in Infrastructure)* | Event queue + transactional outbox | Decouples creation from processing | | **SeaweedFS** *(shared, runs in Infrastructure)* | Object storage (S3 + filer) | Stores originals and derivatives (`.m4a` audio, thumbnails) | | **Zitadel** *(shared, runs in Infrastructure)* | Identity (OIDC/OAuth2) | Login and JWT issuance | @@ -25,13 +25,13 @@ ### 1. Uploading a track 1. The client requests `POST /tracks/upload-urls`. The API creates **UploadIntents** (reservations) and returns **presigned PUT URLs** (image + audio). 2. The client uploads the files **directly to SeaweedFS** using those URLs (never through the API). -3. The client calls `POST /tracks` with the `intentId`s. The API validates the intents, creates the `Track` (+ `UserHasTrack`), and publishes `CreateTrackResourcesEvent`. +3. The client calls `POST /tracks` with the `intentId`s. The API validates the intents, creates the `Track` (owned by the user through `OwnerUserId`), and publishes `CreateTrackResourcesEvent`. Albums and playlists follow the same pattern with `POST /albums/upload-picture` / `POST /playlists/upload-picture` and `POST /albums` / `POST /playlists`. 4. The **Worker** consumes the event: moves the originals into place, generates thumbnails, transcodes the audio to `.m4a`, and updates the track's status. See [media-processing.md](media-processing.md) and [storage.md](storage.md) for details. ### 2. Playback (streaming) -1. The client requests `GET /tracks/{id}/stream` (authenticated). The API authorizes it and returns `{ manifestUrl, ticket }` (a URL to the `.m4a` plus an RS256 ticket scoped to that track's folder). +1. The client requests `GET /tracks/{id}/stream` (authenticated, or anonymous when `Playback:AllowAnonymousListening` is on). The API records the listen (`StartListeningCommand`) and issues the ticket. The API authorizes it and returns `{ manifestUrl, ticket }` (a URL to the `.m4a` plus an RS256 ticket scoped to that track's folder). 2. The player opens the audio URL with `?t=` appended (or an `X-Stream-Ticket` header) and downloads it via HTTP range requests. 3. The **StreamingGateway** validates the ticket and forwards the request to the SeaweedFS filer. The bytes flow storage → client. @@ -46,9 +46,10 @@ See [authentication.md](authentication.md). ## Data model (entities) -- **User**: `Id` is Zitadel's numeric `sub` (`long`), plus a name and `ProfilePictureUrl`. -- **Track**: original/derivative file names, `AudioFolderName` (the transcoded `.m4a`'s folder), processing state (`ProcessingStatus`), `LifeCycleStatus`. -- **PlayList**: a user's playlist (`UserId`). -- **PlayListHasTrack**: the tracks inside a playlist (with `Position`). -- **UserHasTrack**: ownership, which user owns which track. -- **UploadIntent**: an upload reservation (key, bucket, expected size, status, expiration) used for validation and quota. +- **User**: `Id` is Zitadel's numeric `sub` (`long`), plus a name and `ProfilePictureUrl`. **UserFollow** links follower and followed. +- **Track**: owned by `OwnerUserId`; `Pictures` (`TrackPictures`) and `Audio` (`TrackAudio`, with `FolderName` = the transcoded `.m4a`'s folder) are owned types with their own processing status; `LifeCycleStatus` (Active, Removing, Failed). **TrackTag** holds its genres, **TrackLike** the likes, **ListeningHistory** the listens. +- **Album** / **AlbumHasTrack**: an album with 1-based track numbers. **PlayList** / **PlayListHasTrack**: a playlist with 0-based positions and a `Visibility` (Private or Public). Both use `EntityPictures` (resized names stay null until processed). +- **Mix** / **MixItem**: server-generated daily mixes, identified by `MixKind` (the web-player renders the title). +- **UploadIntent**: an upload reservation (key, bucket, purpose, expected size, status, expiration) used for validation and quota. + +Every entity that can be listed or removed implements `IHasLifeCycle`; public reads return only `Active` rows, and the ownership checks (`FindOwnedAsync`) treat non-active entities as not found. diff --git a/docs/authentication.md b/docs/authentication.md index c0376b8..dbffd42 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -15,7 +15,7 @@ Login and token issuance are handled by **Zitadel**, not the API. The API is a * ## `sub` is numeric → `User.Id` is `long` Zitadel's `sub` is a 64-bit integer encoded as a string (e.g. `371953080444977155`), **not a GUID**. Because of that: -- `User.Id` and every user-related FK (`PlayList.UserId`, `UserHasTrack.UserId`, `UploadIntent.UserId`) are **`long`**. +- `User.Id` and every user-related FK (`PlayList.OwnerUserId`, `Track.OwnerUserId`, `UploadIntent.UserId`) are **`long`**. - `User.Id` has `[DatabaseGenerated(None)]` so EF doesn't autogenerate it: it's assigned from the `sub`. - `CurrentUser.Id` is `long?` (parsed with `long.TryParse`); `RequiredId` throws if it's missing. diff --git a/docs/development.md b/docs/development.md index a2d7a4f..a52ff9f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -87,13 +87,14 @@ dotnet test backend/Musify.slnx | Project | Covers | Docker | |---|---|---| | `Musify.Domain.Tests` | Value object logic (`TrackAudio`/`TrackPictures`: `IsProcessed`, `IsFailed`, `IsInProgress`). Entities are mostly anemic, with no behavior of their own beyond that. | No | -| `Musify.Application.Tests` | The ~45 handlers and services in `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes). | No | -| `Musify.Infrastructure.Tests` | `Database`/migrations, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, `PlayListPresetSeeder`. | **Yes** | +| `Musify.Application.Tests` | The handlers and services in `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes...). Its `TestDatabase` builds the model with `ModelConfiguration.Apply` from Infrastructure, so it can't drift from the real `Database`. | No | +| `Musify.Infrastructure.Tests` | `Database`/migrations, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, plus the MassTransit layer (lifecycle and picture-update activities, routing slip builders, `ProcessingSlipFaultConsumer`, the saga state machines on the MassTransit test harness) and `RecurringJobsRegistrar`. | **Yes** | | `Musify.Api.Tests` | Real HTTP endpoints (routing, auth, `ValidationFilter`, `ErrorOr`→HTTP mapping) via `WebApplicationFactory`, plus `ErrorOrHttpExtensions`/`CurrentUser`/FluentValidation validators in isolation. | **Yes** | | `Musify.StreamingGateway.Tests` | `TicketValidator` (RS256) and `TicketValidationMiddleware` (traversal, prefix limits, ticket extraction). | No | `Musify.Worker` has no test project: its `Program.cs` is pure DI/host wiring -(Hangfire, MassTransit, jobs). The actual logic it runs lives in +(Hangfire, MassTransit, jobs), and the container is validated on build, so a +missing registration fails at startup. The actual logic it runs lives in `Musify.Infrastructure`, already covered there. ### Application.Tests: why in-memory SQLite instead of mocks diff --git a/docs/media-processing.md b/docs/media-processing.md index 8ee9836..dd521ab 100644 --- a/docs/media-processing.md +++ b/docs/media-processing.md @@ -10,22 +10,24 @@ Heavy processing (audio transcoding, thumbnails, bucket transfers) happens **out ## Trigger -When a track or playlist is created or updated, the handler publishes an event (e.g. `CreateTrackResourcesEvent`). A **consumer** (`CreateTrackConsumer`, `UpdateTrackPictureConsumer`, …) builds a **routing slip** with the sequence of activities and executes it. +When a track, album or playlist is created, updated or deleted, the handler publishes an event (e.g. `CreateTrackResourcesEvent`, `UpdateAlbumPictureSourceEvent`, `DeleteAlbumEvent`). A **consumer** (`CreateTrackConsumer`, `UpdateTrackPictureConsumer`, `DeleteAlbumConsumer`, …) builds a **routing slip** with the sequence of activities and executes it. Every slip starts from `RoutingSlips.Create(correlationId)` and adds steps with `AddStep(...)`; `TrackFaults(subjectId, processKind)` makes a failed slip publish a failure event for its subject (`ProcessingSlipFaultConsumer`, one process kind per workflow: `TrackPicture`, `TrackAudio`, `PlayListPicture`, `AlbumPicture` and the three `*Creation` kinds). ## Routing slip builders - `CreateTrackRoutingSlipBuilder`: creating a track (consumes intents, moves files, kicks off processing). - `PictureWorkflowRoutingSlipBuilder`: image pipeline (download → resize → upload → update). - `AudioWorkflowRoutingSlipBuilder`: audio pipeline (download → transcode to `.m4a` → upload folder → update). -- `DeleteTrackRoutingSlipBuilder`, `DeletePlayListRoutingSlipBuilder`, `PlayListPictureSourceRoutingSlipBuilder`. +- `DeleteRoutingSlipBuilder`: marks a track, album or playlist as `Removing`, removes its files from the bucket and finally deletes the row. +- `PictureSourceRoutingSlipBuilder`: album and playlist pictures (copy the uploaded file to its final key, consume the intent, publish the event that starts the picture workflow), for both creation and update. ## Activities (steps) Grouped by area: - **Files**: `DownloadFileFromBucketActivity`, `UploadFileToBucketActivity`, `TransferFilesToBucketActivity`, `CopyFileInBucketActivity`, `RemoveFileFromBucketActivity`. - **Audio**: `GenerateAudioWorkflowPathsActivity`, `TranscodeAudioActivity` (ffmpeg → `.m4a`), `UpdateTrackAudioActivity` (saves `AudioFolderName` and marks it `Completed`). -- **Pictures**: `GeneratePictureWorkflowPathsActivity`, `ResizePictureActivity` (ImageSharp), `UpdateTrackPictureActivity`, `UpdatePlayListPictureActivity`. -- **Tracks/Playlists**: `MarkTrackAsRemovingActivity`, `DeleteTrackFromDbActivity`, `PublishTrackProcessingEventsActivity`, and their playlist equivalents. +- **Pictures**: `GeneratePictureWorkflowPathsActivity`, `ResizePictureActivity` (ImageSharp), `UpdateTrackPictureActivity`, `UpdatePlayListPictureActivity`, `UpdateAlbumPictureActivity` (a shared `UpdatePicturesActivity` base; compensation restores the previous names). +- **Life cycle**: `Mark{Track,PlayList,Album}LifeCycleActivity` and `Delete{Track,PlayList,Album}Activity` (generic over the entity; a missing row is skipped). +- **Events**: `PublishTrackProcessingEventsActivity`, `PublishPlayListPictureProcessingEventActivity`, `PublishAlbumPictureProcessingEventActivity`. - **UploadIntents**: `ConsumeUploadIntentsActivity`. `RoutingSlipCleanUpConsumer` reacts to `Completed`/`Faulted` to clean up (e.g. deleting the temporary working directory). @@ -36,11 +38,15 @@ Grouped by area: ## What the Worker needs configured -The Worker registers: storage (S3), transcoder (ffmpeg), pictures, the DB, the MassTransit consumers, and the upload-intent jobs. Its `appsettings` needs the right sections: `Bucket` (= `webapi-storage`, same as the API), `PlayList`, `Track`, `MassTransit`, `AudioTranscoder`, `UploadIntent`, `Workers` (temp directory), `InfrastructureStorage`, `Database`. +The Worker calls `AddApplication` (every Application handler is registered, so its container is validated on build), plus observability, the DB, storage (S3), the transcoder (ffmpeg), pictures, stream tickets, the MassTransit consumers and the Hangfire jobs. Its `appsettings.json` mirrors the API for the shared sections: `ApplicationStorage` (the same bucket as the API), `PlayList`, `Album`, `Track`, `Mix`, `UploadIntent`, `StreamGateway`, `StreamTicket` and `Playback` (the handlers need them to be constructed; the Worker never issues tickets, so the private key is never read), and adds `MassTransit`, `AudioTranscoder`, `Workers` (temp directory), `InfrastructureStorage`, `Database` and `OpenTelemetry`. ## Background jobs -- `UploadIntentExpirationJob`: marks expired intents as such (freeing quota) every `ExpirationJobIntervalSeconds`. -- `TemporalUploadsCleanUpJob`: cleans up old temporary uploads. +Recurring **Hangfire** jobs (Postgres storage), registered by `RecurringJobsRegistrar` when the Worker starts: -Both run on the Worker host (`AddUploadIntentJobs`). If they aren't registered anywhere, stale intents never get cleaned up on their own. +- `DailyMixGenerationJob` (`daily-mix-generation`): regenerates the mixes every day. +- `ListeningHistoryCleanupJob` (`listening-history-cleanup`): prunes old listens every day at 03:00. +- `UploadIntentExpirationJob` (`upload-intent-expiration`): marks expired intents as such (freeing quota) every `ExpirationJobIntervalSeconds`. +- `TempUploadsCleanupJob` (`temp-uploads-cleanup`): cleans up old temporary uploads under `TempRootPrefix` (`TempUploadsRetentionDays`) every `TempCleanupJobIntervalSeconds`. + +Hangfire cron has a one-minute resolution, so intervals in seconds are rounded up to whole minutes (`RecurringJobsRegistrar.EveryCron`). If the Worker isn't running, stale intents never get cleaned up on their own. diff --git a/docs/projects.md b/docs/projects.md index 2549e0e..1d80d08 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -7,13 +7,14 @@ The .NET projects live under `backend/` (solution `Musify.slnx`, folders The public REST API (Minimal APIs, .NET 10). The thin public face of the system. -- **Endpoints**: `UserEndpoints`, `TrackEndpoints`, `PlayListEndpoints`. +- **Endpoints**: `AlbumEndpoints`, `ConfigEndpoints`, `GenreEndpoints`, `LikeEndpoints`, `MixEndpoints`, `PlayListEndpoints`, `TrackEndpoints`, `UserEndpoints`. Every endpoint returns `IResult`; routes use `{id:guid}` (sub-resources `{trackId:guid}`) and `{id:long}` / `{userId:long}` for user ids; list endpoints bind `PageQuery` (`pageNumber`, `pageSize` 1..100). The three `/cover` routes share `CoverEndpoint.Stream`. +- **Validation**: FluentValidation validators in `Validators/`; every limit lives in `Validators/Limits.cs` (the web-player copies the same values) and the upload allow-lists in `Validators/Uploads.cs`. - **Authentication**: JWT Bearer against Zitadel (`Authentication/`), with a `JwtBearerEventsHandler` that syncs the user on every validated token. - **`CurrentUser`**: an abstraction bound as an endpoint parameter (`IBindableFromHttpContext`) that exposes the basic claims (`Id` as `long?`, `RequiredId`, name, etc.) without reading `ClaimsPrincipal` by hand. - **Issues**: presigned upload URLs (via `IStorageService`) and **stream tickets** (via `IStreamTicketService`, RS256). - **OpenAPI + Scalar** for docs/testing; OAuth2 (PKCE) set up for logging in from Scalar. - **CORS** wide open, but **only in Development** (for the local frontend). -- Translates `ErrorOr` into HTTP in `ResultHttpExtensions`. +- Translates `ErrorOr` into HTTP (ProblemDetails) in `ErrorOrHttpExtensions`. **Purpose**: keep the public surface (authorization + metadata) separate from processing and byte-serving. @@ -26,30 +27,31 @@ Entities (`Entities/`) and value objects/enums (`ValueObjects/`: `ProcessingStat ### Application Use cases (CQRS with Mediator): -- `Tracks/`, `PlayLists/`, `Users/` → `Commands`, `Queries`, `Handlers`, `Responses`. +- One folder per feature (`Albums/`, `Genres/`, `Likes/`, `Mixes/`, `Pictures/`, `PlayLists/`, `Tracks/`, `Users/`, …): each `.cs` holds the command/query record and its handler; DTOs live in `Responses/`. - `Contracts/` → interfaces that infrastructure implements (`IDatabase`, `IStorageService`, `IAudioTranscoderService`, `IPictureService`, `IEventBus`, `IStreamTicketService`). -- `Configuration/` → typed options (Track, PlayList, ApplicationStorage, UploadIntent…) bound and validated at startup. -- `Shared/` → cross-cutting helpers (`StorageKey.Combine`, `ImageSize`). -- `Pagination/` → `PaginatedResponse`. +- `Configuration/` → typed options (Track, PlayList, Album, Mix, ApplicationStorage, UploadIntent, Playback, StreamGateway…), each ending in `Configuration`, bound and validated at startup with `AddValidatedOptions`; `Pictures.cs` holds the shared picture routes and sizes. +- `Shared/` → cross-cutting helpers: `StorageKey.Combine`, `ImageSize(s)`, `AppErrors`, `PageRequest` / `PaginatedResponse`, `OwnershipExtensions.FindOwnedAsync`, `TextNormalizer`. +- `Services/` → `UploadIntentIssuer` / `UploadIntentValidator` (upload intents) and `TrackStreamIssuer` (stream tickets). +- `AddApplication(configuration)` registers all of the above; the API and the Worker both call it. **Why**: business logic doesn't know about EF, S3, or RabbitMQ, only interfaces. Shared by both the API and the Worker. ### Infrastructure Concrete implementations: -- `Persistence/` → `Database` (EF Core/Npgsql), migrations. +- `Persistence/` → `Database` (EF Core/Npgsql), migrations, and `ModelConfiguration.Apply` (the entity model, shared with the in-memory test database). - `Services/` → `StorageService` (AWSSDK.S3 against SeaweedFS), `AudioTranscoderService` (ffmpeg → `.m4a`), `PictureService` (ImageSharp), `StreamTicketService` (RS256 signing). -- `MassTransit/` → consumers, activities, and routing slips (see [media-processing.md](media-processing.md)). -- `Jobs/` → `UploadIntentExpirationJob`, `TemporalUploadsCleanUpJob`. +- `MassTransit/` → consumers, activities, routing slips and sagas (see [media-processing.md](media-processing.md)). +- `Jobs/` → the recurring Hangfire jobs (`DailyMixGenerationJob`, `ListeningHistoryCleanupJob`, `UploadIntentExpirationJob`, `TempUploadsCleanupJob`) and `RecurringJobsRegistrar`. ## Musify.StreamingGateway (`:8081`) -A reverse proxy (ASP.NET + **YARP**) that serves audio (`.m4a`) from SeaweedFS. Validates the stream ticket (RS256, public key) and, if the requested object falls under the authorized prefix, forwards it to the filer. +A reverse proxy (ASP.NET + **YARP**) that serves audio (`.m4a`) from SeaweedFS. Validates the stream ticket (RS256, public key) and, if the requested object falls under the authorized prefix, forwards it to the filer. It references neither Application nor Infrastructure and registers its own options. The bucket in the default YARP transform is the placeholder `CHANGE_ME` (compose overrides it; `appsettings.Development.json` sets `webapi-storage`). **Why a separate project**: it scales with byte traffic independently of the API, has a minimal surface (it only knows how to validate a JWT and proxy), and lets SeaweedFS stay on a private network. See [streaming.md](streaming.md). ## Musify.Worker -A background Worker Service that consumes MassTransit events and runs the heavy processing: audio transcoding to `.m4a` (ffmpeg), thumbnail generation (ImageSharp), file transfers within the bucket, and the upload-intent expiration/cleanup jobs. +A background Worker Service that consumes MassTransit events and runs the heavy processing: audio transcoding to `.m4a` (ffmpeg), thumbnail generation (ImageSharp), file transfers within the bucket, and the recurring Hangfire jobs (daily mixes, listening-history cleanup, upload-intent expiration, temporary upload cleanup). **Why separate**: CPU/IO-heavy work shouldn't happen inside an HTTP request; it scales and restarts independently. (Previously named `Musify.PictureWorker`; renamed once it started doing more than just images.) @@ -63,8 +65,9 @@ with the access token, and uploads go straight to the presigned S3 URLs. `backend/Tests/` (the `/Tests/` folder in the `.slnx`) has five xUnit projects: `Musify.Domain.Tests`, `Musify.Application.Tests` (in-memory SQLite, no -Docker), `Musify.Infrastructure.Tests` and `Musify.Api.Tests` (Testcontainers: -real Postgres + SeaweedFS), and `Musify.StreamingGateway.Tests`. Details in +Docker), `Musify.Infrastructure.Tests` (Testcontainers: real Postgres + +SeaweedFS; also covers the MassTransit activities, routing slips, sagas and +jobs), `Musify.Api.Tests` (Testcontainers) and `Musify.StreamingGateway.Tests`. Details in [development.md](development.md#tests). ## Other folders diff --git a/docs/storage.md b/docs/storage.md index 7f80bbf..52c21ab 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -13,26 +13,28 @@ App bucket: **`webapi-storage`** (`ApplicationStorage:Bucket`). Important: the A ## Key layout -- `temp/{userId}/Tracks/{guid}.{ext}`: temporary uploads, the target of presigned PUTs, before the intent is consumed. -- `uploads/{userId}/Tracks/OriginalPictures|OriginalAudios/...`: originals that have already been consumed. -- `Tracks/SmallPictures|MediumPictures|LargePictures/...`: generated thumbnails. +The layout is configured per owner (the `Track`, `PlayList` and `Album` sections, `Routes`); `ParentFolder` is `Tracks`, `PlayLists` or `Albums`. + +- `{TempRootPrefix}/{userId}/{ParentFolder}/{guid}.{ext}`: temporary uploads, the target of presigned PUTs, before the intent is consumed (`UploadIntent:TempRootPrefix`, default `temp`; objects under the old `temporal/` prefix from earlier versions may need a one-off clean-up). +- `uploads/{userId}/{ParentFolder}/OriginalPictures|OriginalAudios/...`: originals that have already been consumed. +- `{ParentFolder}/SmallPictures|MediumPictures|LargePictures/...`: generated thumbnails. - `Tracks/ProcessedAudios/{AudioFolderName}/`: transcoded audio (`audio.m4a`, AAC with faststart). Keys are always built with the `StorageKey.Combine(...)` helper (in `Application/Shared`), which joins segments with `/` and trims stray spaces and slashes. It's centralized to keep the logic consistent and avoid repeating it. -> In the filer, S3 objects live under `/buckets/{bucket}/{key}`, which is why the gateway rewrites `/media/{key}` → `/buckets/webapi-storage/{key}`. +> In the filer, S3 objects live under `/buckets/{bucket}/{key}`, which is why the gateway rewrites `/media/{key}` → `/buckets/{bucket}/{key}` (the bucket comes from the compose override `S3_BUCKET`; the default in the gateway's `appsettings.json` is the placeholder `CHANGE_ME`). ## Presigned URLs To **avoid** routing large files through the API, the client talks to S3 directly using short-lived signed URLs: -- **Uploads (PUT)**: `RequestTrackUploadUrls` / `RequestPlayListPictureUpload` generate presigned PUTs (with signed `Content-Type` and `If-None-Match: *`, ~120s expiry). The client uploads straight to S3. +- **Uploads (PUT)**: `RequestTrackUploadUrls` / `RequestAlbumPictureUpload` / `RequestPlayListPictureUpload` (issued by `UploadIntentIssuer`) generate presigned PUTs (with signed `Content-Type` and `If-None-Match: *`, ~120s expiry). The client uploads straight to S3. - **Images (GET)**: thumbnails are served via presigned GETs (one object = one URL). **Why presigned instead of proxying**: routing a ~100 MB audio file through the API would burn memory and bandwidth on the application servers and wouldn't scale well. A direct presigned PUT to S3 is the standard, correct pattern here. ### Upload intents and quota -Every `upload-urls` call creates **UploadIntents** (reservations in the `Issued` state) and returns the URLs. They're **consumed** when the track/playlist is created. There's a per-user quota (`MaxActiveUploadIntentsPerUser`, byte limits…) to prevent abuse. The `UploadIntentExpirationJob` (in the Worker) marks expired intents and frees up quota. If that job isn't running, stale intents pile up and block new uploads. +Every `upload-urls` call creates **UploadIntents** (reservations in the `Issued` state) and returns the URLs. They're **consumed** when the track/playlist is created. There's a per-user quota (`MaxActiveUploadIntentsPerUser`, byte limits…) to prevent abuse. The `UploadIntentExpirationJob` (a Hangfire job on the Worker) marks expired intents and frees up quota, and `TempUploadsCleanupJob` deletes abandoned temporary objects. If the Worker isn't running, stale intents pile up and block new uploads. An intent is bound to its purpose (track picture/audio, album picture, playlist picture) and the same intent can't be used for two files. ## What's public and what's private (deployment) diff --git a/docs/streaming.md b/docs/streaming.md index 1f6ba1a..71a1fef 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -37,7 +37,7 @@ Access is authorized for **the track's folder prefix** via a short-lived *stream `TicketValidationMiddleware`, on every request to `/media/**`: 1. Reads `?t=`, validates its signature/aud/iss/exp. 2. Checks that the requested key **starts with** the ticket's `prefix`. If not → 403. -3. Strips the `?t=` and passes the request on to YARP, which rewrites `/media/{key}` → `/buckets/webapi-storage/{key}` for the filer. +3. Strips the `?t=` and passes the request on to YARP, which rewrites `/media/{key}` → `/buckets/{bucket}/{key}` for the filer (the bucket is set by the compose override `ReverseProxy__Routes__media__Transforms__0__PathPattern`; the default in `appsettings.json` is the placeholder `CHANGE_ME`, and `appsettings.Development.json` uses `webapi-storage`). - No ticket → **401**; outside the prefix → **403**; ok → **200** (and **206** for range requests, so seeking works). - Passes through `Range`/`Accept-Ranges` and exposes those headers via CORS. @@ -50,6 +50,6 @@ Yes. Each ticket is scoped to **one** folder (one track). Playing a different on ## Open items / security notes -- Today, the policy for who can request a ticket is "any authenticated user" (TODO: restrict it with `UserHasTrack`/visibility). -- The prefix check is a literal `StartsWith`; it would be worth hardening it against `..` path traversal, even though browsers and ASP.NET normalize `..`. +- Who can request a ticket is decided by `StartListeningCommand`: any authenticated user, or anonymous users when `Playback:AllowAnonymousListening` is on (the ticket then carries `maxBytes`, an anonymous preview limit). TODO: restrict it by track visibility. +- The prefix check is a literal `StartsWith` on the key, and the gateway rejects any `.`/`..` segment with 400. For tickets with `maxBytes` it clamps the `Range` header to the preview size, replaces a missing or invalid range, and answers 416 to suffix ranges or ranges that start beyond the limit. - The ticket travels in the query string (`?t=`); the gateway strips it before forwarding, so it never reaches the filer's logs. From 16478678b5a8a889eba49dd3a0766940e03b3303 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 13:14:05 +0200 Subject: [PATCH 19/21] Use System.Text.Json number handling for long values --- backend/Hosts/Musify.Api/Program.cs | 8 ++-- .../Serialization/LongAsStringConverter.cs | 37 ------------------- 2 files changed, 4 insertions(+), 41 deletions(-) delete mode 100644 backend/Hosts/Musify.Api/Serialization/LongAsStringConverter.cs diff --git a/backend/Hosts/Musify.Api/Program.cs b/backend/Hosts/Musify.Api/Program.cs index 71b704f..fd874db 100644 --- a/backend/Hosts/Musify.Api/Program.cs +++ b/backend/Hosts/Musify.Api/Program.cs @@ -1,7 +1,6 @@ using System.Text.Json.Serialization; using Musify.Api.Endpoints; using Musify.Api.Extensions; -using Musify.Api.Serialization; using Musify.Infrastructure.Observability; using Scalar.AspNetCore; @@ -12,9 +11,10 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); - options.SerializerOptions.Converters.Add(new LongAsStringConverter()); - options.SerializerOptions.Converters.Add(new NullableLongAsStringConverter()); -}); + options.SerializerOptions.NumberHandling = + JsonNumberHandling.AllowReadingFromString | + JsonNumberHandling.WriteAsString; +} ); builder.Services .AddApplicationServices(builder.Configuration) diff --git a/backend/Hosts/Musify.Api/Serialization/LongAsStringConverter.cs b/backend/Hosts/Musify.Api/Serialization/LongAsStringConverter.cs deleted file mode 100644 index 587708c..0000000 --- a/backend/Hosts/Musify.Api/Serialization/LongAsStringConverter.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Globalization; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Musify.Api.Serialization; - -public sealed class LongAsStringConverter : JsonConverter -{ - public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => - reader.TokenType == JsonTokenType.String - ? long.Parse(reader.GetString()!, CultureInfo.InvariantCulture) - : reader.GetInt64(); - - public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) => - writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture)); -} - -public sealed class NullableLongAsStringConverter : JsonConverter -{ - public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.Null) - return null; - - return reader.TokenType == JsonTokenType.String - ? string.IsNullOrEmpty(reader.GetString()) ? null : long.Parse(reader.GetString()!, CultureInfo.InvariantCulture) - : reader.GetInt64(); - } - - public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options) - { - if (value.HasValue) - writer.WriteStringValue(value.Value.ToString(CultureInfo.InvariantCulture)); - else - writer.WriteNullValue(); - } -} From 73e37dbecdba05339dfb86f0241d427845713137 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 13:24:43 +0200 Subject: [PATCH 20/21] Change editor rule --- backend/.editorconfig | 2 +- backend/CLAUDE.md | 2 +- .../Albums/AddTracksToAlbum.cs | 123 +- .../Musify.Application/Albums/CreateAlbum.cs | 97 +- .../Musify.Application/Albums/DeleteAlbum.cs | 41 +- .../Musify.Application/Albums/GetAlbumById.cs | 37 +- .../Albums/GetAlbumTracks.cs | 53 +- .../Musify.Application/Albums/GetAlbums.cs | 43 +- .../Albums/GetAlbumsByUserId.cs | 45 +- .../Albums/GetRecentlyListenedAlbums.cs | 75 +- .../Albums/RemoveTrackFromAlbum.cs | 55 +- .../Albums/RequestAlbumPictureUpload.cs | 31 +- .../Responses/AlbumApplicationResponse.cs | 69 +- .../Albums/Responses/AlbumProjections.cs | 49 +- .../Musify.Application/Albums/UpdateAlbum.cs | 105 +- .../ApplicationDependencyInjection.cs | 35 +- .../Configuration/AlbumConfiguration.cs | 17 +- .../ApplicationStorageConfiguration.cs | 15 +- .../ConfigurationDependencyInjection.cs | 39 +- .../Configuration/GetPlaybackPublicConfig.cs | 23 +- .../Configuration/IConfigurationOptions.cs | 9 +- .../Configuration/MixConfiguration.cs | 13 +- .../Configuration/Pictures.cs | 163 +-- .../Configuration/PlayListConfiguration.cs | 17 +- .../Configuration/PlaybackConfiguration.cs | 21 +- .../Responses/PlaybackPublicConfigResponse.cs | 7 +- .../StreamGatewayConfiguration.cs | 19 +- .../Configuration/TrackConfiguration.cs | 75 +- .../UploadIntentConfiguration.cs | 53 +- .../Contracts/AudioTranscodeResult.cs | 7 +- .../Contracts/IAudioTranscoderService.cs | 9 +- .../Musify.Application/Contracts/IDatabase.cs | 41 +- .../Contracts/IDatabaseTransaction.cs | 9 +- .../Musify.Application/Contracts/IEventBus.cs | 9 +- .../Contracts/IPictureService.cs | 9 +- .../Contracts/IStorageService.cs | 29 +- .../Contracts/IStreamTicketService.cs | 13 +- .../Contracts/ObjectMetaData.cs | 7 +- .../Contracts/StorageObject.cs | 7 +- .../Events/AlbumPictureProcessed.cs | 7 +- .../Events/AlbumPictureProcessingFailed.cs | 7 +- .../Events/AlbumProcessingFailed.cs | 13 +- .../Events/CreateAlbumResourcesEvent.cs | 19 +- .../Events/CreatePlayListResourcesEvent.cs | 19 +- .../Events/CreateTrackResourcesEvent.cs | 27 +- .../Events/DeleteAlbumEvent.cs | 11 +- .../Events/DeletePlayListEvent.cs | 11 +- .../Events/DeleteTrackEvent.cs | 11 +- .../Events/PlayListPictureProcessed.cs | 7 +- .../Events/PlayListPictureProcessingFailed.cs | 7 +- .../Events/PlayListProcessingFailed.cs | 13 +- .../Events/TrackAudioProcessed.cs | 7 +- .../Events/TrackAudioProcessingFailed.cs | 7 +- .../Events/TrackPictureProcessed.cs | 7 +- .../Events/TrackPictureProcessingFailed.cs | 7 +- .../Events/TrackProcessingFailed.cs | 15 +- .../Events/UpdateAlbumPictureEvent.cs | 15 +- .../Events/UpdateAlbumPictureSourceEvent.cs | 19 +- .../Events/UpdatePlayListPictureEvent.cs | 15 +- .../UpdatePlayListPictureSourceEvent.cs | 19 +- .../Events/UpdateTrackAudioEvent.cs | 17 +- .../Events/UpdateTrackPictureEvent.cs | 15 +- .../Genres/GetAvailableGenres.cs | 29 +- .../Musify.Application/Genres/GetGenres.cs | 39 +- .../Responses/AvailableGenreResponse.cs | 7 +- .../Genres/Responses/GenreResponse.cs | 7 +- .../Likes/GetLikedTracks.cs | 39 +- .../Likes/ToggleTrackLike.cs | 85 +- .../Mixes/GenerateMixesForUser.cs | 205 +-- .../Musify.Application/Mixes/GetMixById.cs | 35 +- .../Mixes/GetMixesByUserId.cs | 75 +- .../Musify.Application/Mixes/MixItemMapper.cs | 57 +- .../Mixes/Responses/MixApplicationResponse.cs | 17 +- .../Responses/MixItemApplicationResponse.cs | 17 +- .../Musify.Application/Pictures/GetCover.cs | 113 +- .../Pictures/PictureSourceChange.cs | 45 +- .../Responses/PictureUploadResponse.cs | 21 +- .../PlayLists/AddTracksToPlayList.cs | 115 +- .../PlayLists/CreatePlayList.cs | 115 +- .../PlayLists/DeletePlayList.cs | 41 +- .../PlayLists/GetPlayListById.cs | 39 +- .../PlayLists/GetPlayListTracks.cs | 57 +- .../PlayLists/GetPlayListsByUserId.cs | 57 +- .../PlayLists/RemoveTrackFromPlayList.cs | 55 +- .../PlayLists/RequestPlayListPictureUpload.cs | 31 +- .../Responses/PlayListApplicationResponse.cs | 75 +- .../Responses/PlayListProjections.cs | 59 +- .../PlayLists/UpdatePlayList.cs | 123 +- .../Services/TrackStreamIssuer.cs | 47 +- .../Services/UploadIntentIssuer.cs | 223 ++-- .../Services/UploadIntentValidator.cs | 107 +- .../Musify.Application/Shared/AppErrors.cs | 19 +- .../Musify.Application/Shared/ImageSize.cs | 21 +- .../Shared/OwnershipExtensions.cs | 35 +- .../Musify.Application/Shared/Pagination.cs | 73 +- .../Musify.Application/Shared/StorageKey.cs | 15 +- .../Shared/TextNormalizer.cs | 9 +- .../Musify.Application/Tracks/CreateTrack.cs | 175 +-- .../Tracks/DeleteStaleUncountedListens.cs | 39 +- .../Musify.Application/Tracks/DeleteTrack.cs | 49 +- .../Tracks/GetLastTrackListenedByUserId.cs | 33 +- .../Tracks/GetListeningHistory.cs | 55 +- .../Musify.Application/Tracks/GetTrackById.cs | 37 +- .../Musify.Application/Tracks/GetTracks.cs | 49 +- .../Tracks/GetTracksByUserId.cs | 53 +- .../Tracks/ListeningRules.cs | 21 +- .../Tracks/RecordListeningProgress.cs | 53 +- .../Tracks/RequestTrackUploadUrls.cs | 89 +- .../Responses/TrackApplicationResponse.cs | 55 +- .../Tracks/Responses/TrackProjections.cs | 37 +- .../Tracks/Responses/TrackStreamResponse.cs | 15 +- .../Responses/TrackUploadUrlsResponse.cs | 31 +- .../Tracks/StartListening.cs | 75 +- .../UploadIntents/DeleteStaleTempUploads.cs | 53 +- .../UploadIntents/ExpireUploadIntents.cs | 57 +- .../Musify.Application/Users/FollowUser.cs | 73 +- .../Users/FollowVisibility.cs | 39 +- .../Users/GetListeningStats.cs | 83 +- .../Musify.Application/Users/GetUserById.cs | 35 +- .../Users/GetUserFollowers.cs | 53 +- .../Users/GetUserFollowing.cs | 49 +- .../Users/GetUserProfile.cs | 97 +- .../Core/Musify.Application/Users/GetUsers.cs | 57 +- .../Users/IsFollowingUser.cs | 21 +- .../Users/Responses/ListeningStatsResponse.cs | 7 +- .../Responses/UserApplicationResponse.cs | 39 +- .../Users/Responses/UserProfileResponse.cs | 27 +- .../Users/Responses/UserSummaryResponse.cs | 19 +- .../Core/Musify.Application/Users/SyncUser.cs | 81 +- .../Musify.Application/Users/UnfollowUser.cs | 39 +- .../Musify.Domain/Abstractions/IAuditable.cs | 11 +- .../Abstractions/IHasLifeCycle.cs | 11 +- .../Abstractions/IOwnedEntity.cs | 9 +- backend/Core/Musify.Domain/Entities/Album.cs | 43 +- .../Musify.Domain/Entities/AlbumHasTrack.cs | 27 +- .../Entities/ListeningHistory.cs | 33 +- backend/Core/Musify.Domain/Entities/Mix.cs | 29 +- .../Core/Musify.Domain/Entities/MixItem.cs | 27 +- .../Core/Musify.Domain/Entities/PlayList.cs | 45 +- .../Entities/PlayListHasTrack.cs | 27 +- backend/Core/Musify.Domain/Entities/Track.cs | 49 +- .../Core/Musify.Domain/Entities/TrackLike.cs | 27 +- .../Core/Musify.Domain/Entities/TrackTag.cs | 21 +- .../Musify.Domain/Entities/UploadIntent.cs | 51 +- backend/Core/Musify.Domain/Entities/User.cs | 43 +- .../Core/Musify.Domain/Entities/UserFollow.cs | 27 +- .../ValueObjects/EntityPictures.cs | 21 +- .../Core/Musify.Domain/ValueObjects/Genre.cs | 59 +- .../ValueObjects/GenreCompatibility.cs | 75 +- .../ValueObjects/LifeCycleStatus.cs | 13 +- .../Musify.Domain/ValueObjects/MixKind.cs | 11 +- .../ValueObjects/PlaylistVisibility.cs | 11 +- .../ValueObjects/ProcessingStatus.cs | 15 +- .../Musify.Domain/ValueObjects/TrackAudio.cs | 21 +- .../ValueObjects/TrackPictures.cs | 25 +- .../ValueObjects/UploadIntentPurpose.cs | 15 +- .../ValueObjects/UploadIntentStatus.cs | 13 +- .../Configuration/AudioConfiguration.cs | 53 +- .../Configuration/DatabaseConfiguration.cs | 13 +- .../InfrastructureStorageConfiguration.cs | 31 +- .../Configuration/MassTransitConfiguration.cs | 21 +- .../OpenTelemetryConfiguration.cs | 15 +- .../StreamTicketConfiguration.cs | 25 +- .../Configuration/WorkerConfiguration.cs | 23 +- .../Jobs/DailyMixGenerationJob.cs | 35 +- .../Jobs/ListeningHistoryCleanupJob.cs | 11 +- .../Jobs/RecurringJobsRegistrar.cs | 59 +- .../Jobs/TempUploadsCleanupJob.cs | 11 +- .../Jobs/UploadIntentExpirationJob.cs | 11 +- ...lishAlbumPictureProcessingEventActivity.cs | 39 +- .../GenerateAudioWorkflowPathsActivity.cs | 63 +- .../Activities/Audio/TrackAudioStatus.cs | 29 +- .../Audio/TranscodeAudioActivity.cs | 93 +- .../Audio/UpdateTrackAudioActivity.cs | 87 +- .../Files/CopyFileInBucketActivity.cs | 67 +- .../Files/DownloadFileFromBucketActivity.cs | 81 +- .../Files/RemoveFileFromBucketActivity.cs | 51 +- .../Files/TransferFilesToBucketActivity.cs | 77 +- .../Files/UploadFileToBucketActivity.cs | 75 +- .../LifeCycle/LifeCycleActivities.cs | 149 +-- .../GeneratePictureWorkflowPathsActivity.cs | 55 +- .../Pictures/ResizePictureActivity.cs | 95 +- .../Pictures/UpdatePicturesActivity.cs | 247 ++-- ...hPlayListPictureProcessingEventActivity.cs | 39 +- .../PublishTrackProcessingEventsActivity.cs | 55 +- .../ConsumeUploadIntentsActivity.cs | 73 +- .../ConsumeUploadIntentsArguments.cs | 9 +- .../Arguments/CopyFileInBucketArguments.cs | 15 +- .../Arguments/DeleteEntityArguments.cs | 7 +- .../DownloadFileFromBucketArguments.cs | 13 +- .../GenerateAudioWorkflowPathsArguments.cs | 13 +- .../GeneratePictureWorkflowPathsArguments.cs | 11 +- .../Arguments/MarkLifeCycleArguments.cs | 7 +- ...ishAlbumPictureProcessingEventArguments.cs | 15 +- ...PlayListPictureProcessingEventArguments.cs | 15 +- .../PublishTrackProcessingEventsArguments.cs | 19 +- .../RemoveFileFromBucketArguments.cs | 11 +- .../Arguments/ResizePictureLocalArguments.cs | 15 +- .../Arguments/TranscodeAudioArguments.cs | 13 +- .../TransferFilesToBucketArguments.cs | 13 +- .../Arguments/UpdatePicturesArguments.cs | 17 +- .../Arguments/UpdateTrackAudioArguments.cs | 13 +- .../Arguments/UploadFileToBucketArguments.cs | 13 +- .../AlbumProcessingFailedConsumer.cs | 17 +- .../Consumers/CreateAlbumConsumer.cs | 13 +- .../Consumers/CreatePlayListConsumer.cs | 13 +- .../Consumers/CreateTrackConsumer.cs | 13 +- .../Consumers/DeleteAlbumConsumer.cs | 19 +- .../Consumers/DeletePlayListConsumer.cs | 19 +- .../Consumers/DeleteTrackConsumer.cs | 19 +- .../PlayListProcessingFailedConsumer.cs | 17 +- .../Consumers/ProcessingFailedCleanup.cs | 43 +- .../Consumers/ProcessingSlipFaultConsumer.cs | 95 +- .../Consumers/RoutingSlipCleanUpConsumer.cs | 59 +- .../TrackProcessingFailedConsumer.cs | 19 +- .../Consumers/UpdateAlbumPictureConsumer.cs | 13 +- .../UpdateAlbumPictureSourceConsumer.cs | 13 +- .../UpdatePlayListPictureConsumer.cs | 13 +- .../UpdatePlayListPictureSourceConsumer.cs | 13 +- .../Consumers/UpdateTrackAudioConsumer.cs | 13 +- .../Consumers/UpdateTrackPictureConsumer.cs | 13 +- ...TransitDependencyInjection.Registration.cs | 105 +- .../MassTransitDependencyInjection.cs | 173 +-- .../Logs/ConsumeUploadIntentsLog.cs | 9 +- .../MassTransit/Logs/CopyFileInBucketLog.cs | 11 +- .../Logs/DownloadFileFromBucketLog.cs | 13 +- .../Logs/GeneratePictureWorkflowPathsLog.cs | 7 +- .../MassTransit/Logs/ResizePictureLog.cs | 7 +- .../MassTransit/Logs/TranscodeAudioLog.cs | 7 +- .../Logs/TransferFilesToBucketLog.cs | 7 +- .../MassTransit/Logs/UpdatePicturesLog.cs | 17 +- .../MassTransit/Logs/UpdateTrackAudioLog.cs | 13 +- .../MassTransit/Logs/UploadFileToBucketLog.cs | 7 +- .../MassTransit/MassTransitEventBus.cs | 13 +- .../MassTransit/RoutingSlip/ActivityNames.cs | 95 +- .../AudioWorkflowRoutingSlipBuilder.cs | 71 +- .../Builders/CreateTrackRoutingSlipBuilder.cs | 65 +- .../Builders/DeleteRoutingSlipBuilder.cs | 211 ++-- .../PictureSourceRoutingSlipBuilder.cs | 121 +- .../PictureWorkflowRoutingSlipBuilder.cs | 145 +-- .../RoutingSlip/RoutingSlipVariableNames.cs | 69 +- .../MassTransit/RoutingSlip/RoutingSlips.cs | 75 +- .../MassTransit/Sagas/AlbumProcessingState.cs | 19 +- .../Sagas/AlbumProcessingStateMachine.cs | 91 +- .../Sagas/PlayListProcessingState.cs | 19 +- .../Sagas/PlayListProcessingStateMachine.cs | 91 +- .../MassTransit/Sagas/TrackProcessingState.cs | 25 +- .../Sagas/TrackProcessingStateMachine.cs | 165 +-- .../OpenTelemetryDependencyInjection.cs | 73 +- .../Persistence/AuditableEntityInterceptor.cs | 65 +- .../Persistence/Database.cs | 135 +- .../DatabaseDependencyInjection.cs | 17 +- .../Persistence/DatabaseDesignTimeFactory.cs | 31 +- .../Persistence/ModelConfiguration.cs | 245 ++-- .../Services/AudioTranscoderService.cs | 301 ++--- .../Services/PictureService.cs | 39 +- .../Services/ServicesDependencyInjection.cs | 101 +- .../Services/StorageService.cs | 249 ++-- .../Services/StreamTicketService.cs | 81 +- .../AuthenticationConfiguration.cs | 51 +- .../AuthenticationExtensions.cs | 33 +- .../Musify.Api/Authentication/CurrentUser.cs | 53 +- .../Authentication/JwtBearerEventsHandler.cs | 231 ++-- .../Authentication/JwtBearerOptionsSetup.cs | 49 +- .../DataTransferObjects/AddTracksRequest.cs | 7 +- .../Albums/CreateAlbumRequest.cs | 15 +- .../Albums/UpdateAlbumRequest.cs | 15 +- .../Likes/ToggleLikeRequest.cs | 7 +- .../PictureUploadRequest.cs | 13 +- .../PlayLists/CreatePlayListRequest.cs | 15 +- .../PlayLists/UpdatePlayListRequest.cs | 15 +- .../Tracks/CreateTrackRequest.cs | 17 +- .../Tracks/RecordListeningProgressRequest.cs | 7 +- .../Tracks/RequestTrackUploadUrlsRequest.cs | 19 +- .../Musify.Api/Endpoints/AlbumEndpoints.cs | 479 +++---- .../Musify.Api/Endpoints/ConfigEndpoints.cs | 37 +- .../Musify.Api/Endpoints/CoverEndpoint.cs | 41 +- .../Musify.Api/Endpoints/GenreEndpoints.cs | 59 +- .../Musify.Api/Endpoints/LikeEndpoints.cs | 81 +- .../Musify.Api/Endpoints/MixEndpoints.cs | 73 +- .../Musify.Api/Endpoints/PlayListEndpoints.cs | 381 +++--- .../Musify.Api/Endpoints/TrackEndpoints.cs | 337 ++--- .../Musify.Api/Endpoints/UserEndpoints.cs | 347 +++--- .../Extensions/ErrorOrHttpExtensions.cs | 71 +- .../Extensions/ServiceCollectionExtensions.cs | 29 +- .../Musify.Api/Filters/ValidationFilter.cs | 23 +- .../Hosts/Musify.Api/Models/GenreParameter.cs | 23 +- backend/Hosts/Musify.Api/Models/PageQuery.cs | 13 +- .../Musify.Api/OpenApi/OpenApiExtensions.cs | 15 +- .../Musify.Api/OpenApi/OpenApiOptionsSetup.cs | 145 +-- .../Musify.Api/Scalar/ScalarExtensions.cs | 13 +- .../Musify.Api/Scalar/ScalarOptionsSetup.cs | 45 +- .../Validators/AddTracksRequestValidator.cs | 25 +- .../Albums/CreateAlbumRequestValidator.cs | 29 +- .../Albums/UpdateAlbumRequestValidator.cs | 25 +- backend/Hosts/Musify.Api/Validators/Limits.cs | 19 +- .../Validators/PageQueryValidator.cs | 17 +- .../PictureUploadRequestValidator.cs | 21 +- .../CreatePlayListRequestValidator.cs | 23 +- .../UpdatePlayListRequestValidator.cs | 29 +- .../Tracks/CreateTrackRequestValidator.cs | 45 +- ...RecordListeningProgressRequestValidator.cs | 15 +- .../RequestTrackUploadUrlsRequestValidator.cs | 33 +- .../Hosts/Musify.Api/Validators/Uploads.cs | 65 +- .../Authentication/TicketValidator.cs | 99 +- .../StreamTicketValidationConfiguration.cs | 27 +- .../Middleware/TicketValidationMiddleware.cs | 183 +-- .../Authentication/CurrentUserTests.cs | 95 +- .../Endpoints/AlbumEndpointsTests.cs | 185 +-- .../Endpoints/ConfigEndpointsTests.cs | 25 +- .../Endpoints/GenreEndpointsTests.cs | 145 +-- .../ListeningProgressEndpointsTests.cs | 147 +-- .../Endpoints/PlayListEndpointsTests.cs | 225 ++-- .../Endpoints/UserEndpointsTests.cs | 197 +-- .../Extensions/ErrorOrHttpExtensionsTests.cs | 231 ++-- .../Filters/ValidationFilterTests.cs | 83 +- .../TestSupport/ApiTestFixture.cs | 177 +-- .../TestSupport/FakeAuthenticationHandler.cs | 39 +- .../CreateAlbumRequestValidatorTests.cs | 119 +- .../Validators/TrackRequestValidatorTests.cs | 65 +- .../Validators/UploadRequestValidatorTests.cs | 63 +- .../AddTracksToAlbumCommandHandlerTests.cs | 163 +-- .../Albums/CreateAlbumCommandHandlerTests.cs | 101 +- .../Albums/DeleteAlbumCommandHandlerTests.cs | 71 +- .../Albums/GetAlbumByIdQueryHandlerTests.cs | 45 +- .../Albums/GetAlbumTracksQueryHandlerTests.cs | 49 +- .../GetAlbumsByUserIdQueryHandlerTests.cs | 41 +- .../Albums/GetAlbumsQueryHandlerTests.cs | 61 +- ...RecentlyListenedAlbumsQueryHandlerTests.cs | 115 +- ...RemoveTrackFromAlbumCommandHandlerTests.cs | 103 +- ...stAlbumPictureUploadCommandHandlerTests.cs | 99 +- .../Albums/UpdateAlbumCommandHandlerTests.cs | 127 +- .../GetPlaybackPublicConfigTests.cs | 35 +- .../PicturesConfigurationTests.cs | 85 +- .../GetAvailableGenresQueryHandlerTests.cs | 41 +- .../Genres/GetGenresQueryHandlerTests.cs | 55 +- .../Likes/GetLikedTracksQueryHandlerTests.cs | 69 +- .../ToggleTrackLikeCommandHandlerTests.cs | 75 +- ...GenerateMixesForUserCommandHandlerTests.cs | 121 +- .../Mixes/GetMixByIdQueryHandlerTests.cs | 67 +- .../GetMixesByUserIdQueryHandlerTests.cs | 47 +- .../Pictures/GetCoverQueryHandlerTests.cs | 155 +-- .../AddTracksToPlayListCommandHandlerTests.cs | 193 +-- .../CreatePlayListCommandHandlerTests.cs | 153 +-- .../DeletePlayListCommandHandlerTests.cs | 69 +- .../GetPlayListByIdQueryHandlerTests.cs | 117 +- .../GetPlayListTracksQueryHandlerTests.cs | 85 +- .../GetPlayListsByUserIdQueryHandlerTests.cs | 99 +- ...oveTrackFromPlayListCommandHandlerTests.cs | 117 +- ...layListPictureUploadCommandHandlerTests.cs | 99 +- .../UpdatePlayListCommandHandlerTests.cs | 189 +-- .../Services/TrackStreamIssuerTests.cs | 85 +- .../Services/UploadIntentValidatorTests.cs | 221 ++-- .../Shared/StorageKeyTests.cs | 55 +- .../Shared/TrackApplicationResponseTests.cs | 39 +- .../TestSupport/HandlerTestBase.cs | 39 +- .../TestSupport/TestConfigurations.cs | 29 +- .../TestSupport/TestDatabase.cs | 163 +-- .../TestSupport/TestEntities.cs | 357 +++--- .../Tracks/CreateTrackCommandHandlerTests.cs | 187 +-- ...taleUncountedListensCommandHandlerTests.cs | 41 +- .../Tracks/DeleteTrackCommandHandlerTests.cs | 87 +- ...tTrackListenedByUserIdQueryHandlerTests.cs | 51 +- .../GetListeningHistoryQueryHandlerTests.cs | 77 +- .../Tracks/GetTrackByIdQueryHandlerTests.cs | 71 +- .../GetTracksByUserIdQueryHandlerTests.cs | 35 +- .../Tracks/GetTracksQueryHandlerTests.cs | 79 +- ...ordListeningProgressCommandHandlerTests.cs | 205 +-- ...questTrackUploadUrlsCommandHandlerTests.cs | 109 +- .../StartListeningCommandHandlerTests.cs | 137 +- .../UploadIntentJobsCommandHandlerTests.cs | 99 +- .../Users/FollowUserCommandHandlerTests.cs | 79 +- .../GetListeningStatsQueryHandlerTests.cs | 201 +-- .../Users/GetUserByIdQueryHandlerTests.cs | 37 +- .../GetUserFollowersQueryHandlerTests.cs | 133 +- .../GetUserFollowingQueryHandlerTests.cs | 109 +- .../Users/GetUserProfileQueryHandlerTests.cs | 115 +- .../Users/GetUsersQueryHandlerTests.cs | 55 +- .../Users/IsFollowingUserQueryHandlerTests.cs | 43 +- .../Users/SyncUserCommandHandlerTests.cs | 57 +- .../Users/UnfollowUserCommandHandlerTests.cs | 45 +- .../ValueObjects/GenreCompatibilityTests.cs | 75 +- .../ValueObjects/TrackAudioTests.cs | 29 +- .../ValueObjects/TrackPicturesTests.cs | 23 +- .../Jobs/RecurringJobsRegistrarTests.cs | 31 +- .../DeleteRoutingSlipBuilderTests.cs | 373 +++--- .../MassTransit/LifeCycleActivityTests.cs | 333 ++--- .../ProcessingSlipFaultConsumerTests.cs | 187 +-- .../ProcessingStateMachineTests.cs | 385 +++--- .../MassTransit/RoutingSlipBuilderTests.cs | 187 +-- .../UpdatePicturesActivityTests.cs | 449 +++---- .../AuditableEntityInterceptorTests.cs | 71 +- .../Persistence/DatabaseTests.cs | 181 +-- .../Services/PictureServiceTests.cs | 77 +- .../Services/StorageServiceTests.cs | 237 ++-- .../Services/StreamTicketServiceTests.cs | 185 +-- .../TestSupport/InfrastructureTestFixture.cs | 153 +-- .../Authentication/TicketValidatorTests.cs | 283 ++--- .../TicketValidationMiddlewareTests.cs | 389 +++--- backend/docs/backend-refactor-handoff.md | 235 ++++ backend/docs/backend-refactor-plan.md | 1108 +++++++++++++++++ 401 files changed, 14735 insertions(+), 12995 deletions(-) create mode 100644 backend/docs/backend-refactor-handoff.md create mode 100644 backend/docs/backend-refactor-plan.md diff --git a/backend/.editorconfig b/backend/.editorconfig index bcc34a2..e78ac4b 100644 --- a/backend/.editorconfig +++ b/backend/.editorconfig @@ -18,7 +18,7 @@ generated_code = true [*.cs] indent_size = 4 -csharp_style_namespace_declarations = file_scoped:warning +csharp_style_namespace_declarations = block_scoped:warning csharp_using_directive_placement = outside_namespace:warning dotnet_sort_system_directives_first = true dotnet_separate_import_directive_groups = false diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index a8545e7..5a29132 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -19,7 +19,7 @@ dotnet format Musify.slnx --verify-no-changes ## Style -- File-scoped namespaces, usings outside the namespace and sorted (`.editorconfig` enforces it). +- Block-scoped namespaces (`namespace X { }`; file-scoped `namespace X;` is not allowed), usings outside the namespace and sorted (`.editorconfig` enforces it). - Private fields camelCase without underscore, constants and `static readonly` fields PascalCase, explicit accessibility. - `ArgumentNullException.ThrowIfNull(x)` without `nameof`. - Use `PlayList` (not `Playlist`) in code; configuration POCOs end in `Configuration`; injected services are named after their type (`storageService`). diff --git a/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs b/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs index 39a0679..455660e 100644 --- a/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs +++ b/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs @@ -7,71 +7,72 @@ using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Albums; - -/// Appends your own tracks to an album in the given order, skipping the ones it already has. -public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList TrackIds) - : ICommand>; - -public class AddTracksToAlbumCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken) + /// Appends your own tracks to an album in the given order, skipping the ones it already has. + public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList TrackIds) + : ICommand>; + + public class AddTracksToAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); - if (album.IsError) - return album.Errors; - - var requestedIds = request.TrackIds.Distinct().ToList(); - - var tracks = await database.Tracks - .AsNoTracking() - .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) - .Select(track => new { track.Id, track.OwnerUserId }) - .ToListAsync(cancellationToken); - - var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id)); - if (missing != Guid.Empty) - return AppErrors.NotFound("Track", missing); - - var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId); - if (foreign != null) - return AppErrors.Forbidden("Track", foreign.Id); - - var alreadyInAlbum = await database.AlbumHasTracks - .AsNoTracking() - .Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId)) - .Select(link => link.TrackId) - .ToListAsync(cancellationToken); - - var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList(); - if (toAdd.Count == 0) - return Result.Success; - - // Album track numbers are user-visible, so they start at 1. - var nextTrackNumber = (await database.AlbumHasTracks - .Where(link => link.AlbumId == request.AlbumId) - .MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1; - - foreach (var trackId in toAdd) + public async ValueTask> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken) { - await database.AlbumHasTracks.AddAsync(new AlbumHasTrack + var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (album.IsError) + return album.Errors; + + var requestedIds = request.TrackIds.Distinct().ToList(); + + var tracks = await database.Tracks + .AsNoTracking() + .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => new { track.Id, track.OwnerUserId }) + .ToListAsync(cancellationToken); + + var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id)); + if (missing != Guid.Empty) + return AppErrors.NotFound("Track", missing); + + var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId); + if (foreign != null) + return AppErrors.Forbidden("Track", foreign.Id); + + var alreadyInAlbum = await database.AlbumHasTracks + .AsNoTracking() + .Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId)) + .Select(link => link.TrackId) + .ToListAsync(cancellationToken); + + var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList(); + if (toAdd.Count == 0) + return Result.Success; + + // Album track numbers are user-visible, so they start at 1. + var nextTrackNumber = (await database.AlbumHasTracks + .Where(link => link.AlbumId == request.AlbumId) + .MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1; + + foreach (var trackId in toAdd) { - AlbumId = request.AlbumId, - TrackId = trackId, - TrackNumber = nextTrackNumber++ - }, cancellationToken); + await database.AlbumHasTracks.AddAsync(new AlbumHasTrack + { + AlbumId = request.AlbumId, + TrackId = trackId, + TrackNumber = nextTrackNumber++ + }, cancellationToken); + } + + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken); + if (saved.IsError) + return saved; + + logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId); + + return Result.Success; } - - var saved = await database.TrySaveChangesAsync( - AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken); - if (saved.IsError) - return saved; - - logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId); - - return Result.Success; } } diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index de98485..8be75e6 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -12,61 +12,62 @@ using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Albums; - -public record CreateAlbumCommand( - long UserId, - string Title, - string? Description, - int? ReleaseYear, - Guid PictureIntentId) - : ICommand>; - -public class CreateAlbumCommandHandler( - IEventBus eventBus, - IDatabase database, - UploadIntentValidator uploadIntentValidator, - ILogger logger, - AlbumConfiguration albumConfiguration) - : ICommandHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) + public record CreateAlbumCommand( + long UserId, + string Title, + string? Description, + int? ReleaseYear, + Guid PictureIntentId) + : ICommand>; + + public class CreateAlbumCommandHandler( + IEventBus eventBus, + IDatabase database, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + AlbumConfiguration albumConfiguration) + : ICommandHandler> { - var userExists = await database.Users - .AsNoTracking() - .AnyAsync(user => user.Id == request.UserId, cancellationToken); - if (!userExists) - return AppErrors.NotFound("User", request.UserId); - - var prepared = await PictureSourceChange.PrepareAsync( - uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); - if (prepared.IsError) - return prepared.Errors; - - var picture = prepared.Value; - var title = request.Title.Trim(); - - var album = new Album + public async ValueTask> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) { - OwnerUserId = request.UserId, - Title = title, - NormalizedTitle = TextNormalizer.Normalize(title), - Description = request.Description, - ReleaseYear = request.ReleaseYear, - Pictures = picture.Pictures - }; + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(user => user.Id == request.UserId, cancellationToken); + if (!userExists) + return AppErrors.NotFound("User", request.UserId); - await database.Albums.AddAsync(album, cancellationToken); + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; - await eventBus.PublishAsync( - new CreateAlbumResourcesEvent( - album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), - cancellationToken); + var picture = prepared.Value; + var title = request.Title.Trim(); - await database.SaveChangesAsync(cancellationToken); + var album = new Album + { + OwnerUserId = request.UserId, + Title = title, + NormalizedTitle = TextNormalizer.Normalize(title), + Description = request.Description, + ReleaseYear = request.ReleaseYear, + Pictures = picture.Pictures + }; - logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId); + await database.Albums.AddAsync(album, cancellationToken); - return AlbumApplicationResponse.FromEntity(album, trackCount: 0); + await eventBus.PublishAsync( + new CreateAlbumResourcesEvent( + album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), + cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId); + + return AlbumApplicationResponse.FromEntity(album, trackCount: 0); + } } } diff --git a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs index bb4a96e..137aeb5 100644 --- a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs +++ b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs @@ -6,31 +6,32 @@ using Musify.Application.Events; using Musify.Application.Shared; using Musify.Domain.ValueObjects; -namespace Musify.Application.Albums; - -public record DeleteAlbumCommand(long UserId, Guid AlbumId) - : ICommand>; - -public class DeleteAlbumCommandHandler( - IDatabase database, - IEventBus eventBus, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) + public record DeleteAlbumCommand(long UserId, Guid AlbumId) + : ICommand>; + + public class DeleteAlbumCommandHandler( + IDatabase database, + IEventBus eventBus, + ILogger logger) + : ICommandHandler> { - var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); - if (found.IsError) - return found.Errors; + public async ValueTask> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) + { + var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; - var album = found.Value; - album.LifeCycleStatus = LifeCycleStatus.Removing; + var album = found.Value; + album.LifeCycleStatus = LifeCycleStatus.Removing; - await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken); - await database.SaveChangesAsync(cancellationToken); + await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken); + await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId); + logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId); - return Result.Success; + return Result.Success; + } } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumById.cs b/backend/Core/Musify.Application/Albums/GetAlbumById.cs index 20856b6..5461a17 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumById.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumById.cs @@ -5,25 +5,26 @@ using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using Musify.Application.Shared; -namespace Musify.Application.Albums; - -public record GetAlbumByIdQuery(Guid Id) - : IQuery>; - -public class GetAlbumByIdQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken) - { - var album = await database.Albums - .AsNoTracking() - .Active() - .Where(a => a.Id == request.Id) - .SelectResponse() - .SingleOrDefaultAsync(cancellationToken); + public record GetAlbumByIdQuery(Guid Id) + : IQuery>; - return album == null - ? AppErrors.NotFound("Album", request.Id) - : album; + public class GetAlbumByIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken) + { + var album = await database.Albums + .AsNoTracking() + .Active() + .Where(a => a.Id == request.Id) + .SelectResponse() + .SingleOrDefaultAsync(cancellationToken); + + return album == null + ? AppErrors.NotFound("Album", request.Id) + : album; + } } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs index ba9955a..7da6847 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs @@ -6,33 +6,34 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -namespace Musify.Application.Albums; - -public record GetAlbumTracksQuery( - Guid AlbumId, - int PageNumber, - int PageSize) - : IQuery>>; - -public class GetAlbumTracksQueryHandler(IDatabase database) - : IQueryHandler>> +namespace Musify.Application.Albums { - public async ValueTask>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken) - { - var albumExists = await database.Albums - .AsNoTracking() - .Active() - .AnyAsync(album => album.Id == request.AlbumId, cancellationToken); - if (!albumExists) - return AppErrors.NotFound("Album", request.AlbumId); + public record GetAlbumTracksQuery( + Guid AlbumId, + int PageNumber, + int PageSize) + : IQuery>>; - return await database.AlbumHasTracks - .AsNoTracking() - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) - .OrderBy(albumTrack => albumTrack.TrackNumber) - .ThenBy(albumTrack => albumTrack.Id) - .Select(albumTrack => albumTrack.Track) - .SelectResponse() - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + public class GetAlbumTracksQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken) + { + var albumExists = await database.Albums + .AsNoTracking() + .Active() + .AnyAsync(album => album.Id == request.AlbumId, cancellationToken); + if (!albumExists) + return AppErrors.NotFound("Album", request.AlbumId); + + return await database.AlbumHasTracks + .AsNoTracking() + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) + .OrderBy(albumTrack => albumTrack.TrackNumber) + .ThenBy(albumTrack => albumTrack.Id) + .Select(albumTrack => albumTrack.Track) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbums.cs b/backend/Core/Musify.Application/Albums/GetAlbums.cs index 0c7143e..2da6cbc 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbums.cs @@ -4,30 +4,31 @@ using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using Musify.Application.Shared; -namespace Musify.Application.Albums; - -public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) - : IQuery>; - -public class GetAlbumsQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) + public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) + : IQuery>; + + public class GetAlbumsQueryHandler(IDatabase database) + : IQueryHandler> { - var albumsQuery = database.Albums - .AsNoTracking() - .Active(); - - if (!string.IsNullOrWhiteSpace(request.Title)) + public async ValueTask> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) { - var normalizedTitle = TextNormalizer.Normalize(request.Title); - albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle)); - } + var albumsQuery = database.Albums + .AsNoTracking() + .Active(); - return await albumsQuery - .OrderByDescending(album => album.CreatedAt) - .ThenBy(album => album.Id) - .SelectResponse() - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + if (!string.IsNullOrWhiteSpace(request.Title)) + { + var normalizedTitle = TextNormalizer.Normalize(request.Title); + albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle)); + } + + return await albumsQuery + .OrderByDescending(album => album.CreatedAt) + .ThenBy(album => album.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs index dfe03d2..53fe0d7 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs @@ -5,29 +5,30 @@ using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using Musify.Application.Shared; -namespace Musify.Application.Albums; - -public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize) - : IQuery>>; - -public class GetAlbumsByUserIdQueryHandler(IDatabase database) - : IQueryHandler>> +namespace Musify.Application.Albums { - public async ValueTask>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken) - { - var userExists = await database.Users - .AsNoTracking() - .AnyAsync(user => user.Id == request.UserId, cancellationToken); - if (!userExists) - return AppErrors.NotFound("User", request.UserId); + public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize) + : IQuery>>; - return await database.Albums - .AsNoTracking() - .Active() - .Where(album => album.OwnerUserId == request.UserId) - .OrderByDescending(album => album.CreatedAt) - .ThenBy(album => album.Id) - .SelectResponse() - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + public class GetAlbumsByUserIdQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken) + { + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(user => user.Id == request.UserId, cancellationToken); + if (!userExists) + return AppErrors.NotFound("User", request.UserId); + + return await database.Albums + .AsNoTracking() + .Active() + .Where(album => album.OwnerUserId == request.UserId) + .OrderByDescending(album => album.CreatedAt) + .ThenBy(album => album.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs index c038271..2f87e1a 100644 --- a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs @@ -3,49 +3,50 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; -namespace Musify.Application.Albums; - -public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit) - : IQuery>; - -public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) + public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit) + : IQuery>; + + public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database) + : IQueryHandler> { - var listens = database.ListeningHistories - .AsNoTracking() - .Where(history => history.UserId == request.UserId && history.IsCounted); + public async ValueTask> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) + { + var listens = database.ListeningHistories + .AsNoTracking() + .Where(history => history.UserId == request.UserId && history.IsCounted); - var ranked = await database.AlbumHasTracks - .AsNoTracking() - .Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) - .Join( - listens, - albumTrack => albumTrack.TrackId, - history => history.TrackId, - (albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt }) - .GroupBy(entry => entry.AlbumId) - .Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) }) - .OrderByDescending(entry => entry.LastListenedAt) - .ThenBy(entry => entry.AlbumId) - .Take(request.Limit) - .ToListAsync(cancellationToken); + var ranked = await database.AlbumHasTracks + .AsNoTracking() + .Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) + .Join( + listens, + albumTrack => albumTrack.TrackId, + history => history.TrackId, + (albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt }) + .GroupBy(entry => entry.AlbumId) + .Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) }) + .OrderByDescending(entry => entry.LastListenedAt) + .ThenBy(entry => entry.AlbumId) + .Take(request.Limit) + .ToListAsync(cancellationToken); - if (ranked.Count == 0) - return []; + if (ranked.Count == 0) + return []; - var albumIds = ranked.Select(entry => entry.AlbumId).ToList(); + var albumIds = ranked.Select(entry => entry.AlbumId).ToList(); - var albumsById = await database.Albums - .AsNoTracking() - .Where(album => albumIds.Contains(album.Id)) - .SelectResponse() - .ToDictionaryAsync(album => album.Id, cancellationToken); + var albumsById = await database.Albums + .AsNoTracking() + .Where(album => albumIds.Contains(album.Id)) + .SelectResponse() + .ToDictionaryAsync(album => album.Id, cancellationToken); - return ranked - .Where(entry => albumsById.ContainsKey(entry.AlbumId)) - .Select(entry => albumsById[entry.AlbumId]) - .ToList(); + return ranked + .Where(entry => albumsById.ContainsKey(entry.AlbumId)) + .Select(entry => albumsById[entry.AlbumId]) + .ToList(); + } } } diff --git a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs index 2edb9c9..04849fd 100644 --- a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs +++ b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs @@ -5,41 +5,42 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Shared; -namespace Musify.Application.Albums; - -public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) - : ICommand>; - -public class RemoveTrackFromAlbumCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) + public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) + : ICommand>; + + public class RemoveTrackFromAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); - if (album.IsError) - return album.Errors; + public async ValueTask> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) + { + var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (album.IsError) + return album.Errors; - var albumTracks = await database.AlbumHasTracks - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) - .OrderBy(albumTrack => albumTrack.TrackNumber) - .ToListAsync(cancellationToken); + var albumTracks = await database.AlbumHasTracks + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) + .OrderBy(albumTrack => albumTrack.TrackNumber) + .ToListAsync(cancellationToken); - var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId); - if (link == null) - return Error.NotFound("Album.TrackNotFound", "Track is not in the album."); + var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId); + if (link == null) + return Error.NotFound("Album.TrackNotFound", "Track is not in the album."); - database.AlbumHasTracks.Remove(link); + database.AlbumHasTracks.Remove(link); - var trackNumber = 1; - foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id)) - remaining.TrackNumber = trackNumber++; + var trackNumber = 1; + foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id)) + remaining.TrackNumber = trackNumber++; - await database.SaveChangesAsync(cancellationToken); + await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId); + logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId); - return Result.Success; + return Result.Success; + } } } diff --git a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs index 14fdd71..68c0dfb 100644 --- a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs +++ b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs @@ -5,20 +5,21 @@ using Musify.Application.Pictures.Responses; using Musify.Application.Services; using Musify.Domain.ValueObjects; -namespace Musify.Application.Albums; - -public record RequestAlbumPictureUploadCommand( - long UserId, - string FileType, - string ContentType, - long? ExpectedSizeBytes = null) - : ICommand>; - -public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration) - : ICommandHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) => - await issuer.IssuePictureAsync( - request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, - request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); + public record RequestAlbumPictureUploadCommand( + long UserId, + string FileType, + string ContentType, + long? ExpectedSizeBytes = null) + : ICommand>; + + public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration) + : ICommandHandler> + { + public async ValueTask> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) => + await issuer.IssuePictureAsync( + request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, + request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); + } } diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs index 5a97f89..77ce78e 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs @@ -1,40 +1,41 @@ using Musify.Domain.Entities; -namespace Musify.Application.Albums.Responses; - -public record AlbumApplicationResponse( - Guid Id, - string Title, - string? Description, - int? ReleaseYear, - long OwnerUserId, - int TrackCount, - string? SmallImageKeyName, - string? MediumImageKeyName, - string? LargeImageKeyName, - DateTime CreatedAt, - DateTime UpdatedAt, - IReadOnlyList CoverTrackIds) +namespace Musify.Application.Albums.Responses { - public const int CoverTrackCount = AlbumProjections.CoverTrackCount; - - public static AlbumApplicationResponse FromEntity( - Album album, - int trackCount, - IReadOnlyList? coverTrackIds = null) + public record AlbumApplicationResponse( + Guid Id, + string Title, + string? Description, + int? ReleaseYear, + long OwnerUserId, + int TrackCount, + string? SmallImageKeyName, + string? MediumImageKeyName, + string? LargeImageKeyName, + DateTime CreatedAt, + DateTime UpdatedAt, + IReadOnlyList CoverTrackIds) { - return new AlbumApplicationResponse( - album.Id, - album.Title, - album.Description, - album.ReleaseYear, - album.OwnerUserId, - trackCount, - album.Pictures?.SmallName, - album.Pictures?.MediumName, - album.Pictures?.LargeName, - album.CreatedAt, - album.UpdatedAt, - coverTrackIds ?? []); + public const int CoverTrackCount = AlbumProjections.CoverTrackCount; + + public static AlbumApplicationResponse FromEntity( + Album album, + int trackCount, + IReadOnlyList? coverTrackIds = null) + { + return new AlbumApplicationResponse( + album.Id, + album.Title, + album.Description, + album.ReleaseYear, + album.OwnerUserId, + trackCount, + album.Pictures?.SmallName, + album.Pictures?.MediumName, + album.Pictures?.LargeName, + album.CreatedAt, + album.UpdatedAt, + coverTrackIds ?? []); + } } } diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs index 196d1d2..cf4b63b 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs @@ -1,31 +1,32 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Albums.Responses; - -public static class AlbumProjections +namespace Musify.Application.Albums.Responses { - public const int CoverTrackCount = 4; + public static class AlbumProjections + { + public const int CoverTrackCount = 4; - public static IQueryable Active(this IQueryable query) => - query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active); + public static IQueryable Active(this IQueryable query) => + query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active); - public static IQueryable SelectResponse(this IQueryable query) => - query.Select(album => new AlbumApplicationResponse( - album.Id, - album.Title, - album.Description, - album.ReleaseYear, - album.OwnerUserId, - album.AlbumTracks.Count, - album.Pictures!.SmallName, - album.Pictures!.MediumName, - album.Pictures!.LargeName, - album.CreatedAt, - album.UpdatedAt, - album.AlbumTracks - .OrderBy(albumTrack => albumTrack.TrackNumber) - .Take(CoverTrackCount) - .Select(albumTrack => albumTrack.TrackId) - .ToList())); + public static IQueryable SelectResponse(this IQueryable query) => + query.Select(album => new AlbumApplicationResponse( + album.Id, + album.Title, + album.Description, + album.ReleaseYear, + album.OwnerUserId, + album.AlbumTracks.Count, + album.Pictures!.SmallName, + album.Pictures!.MediumName, + album.Pictures!.LargeName, + album.CreatedAt, + album.UpdatedAt, + album.AlbumTracks + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Take(CoverTrackCount) + .Select(albumTrack => albumTrack.TrackId) + .ToList())); + } } diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index b4d798d..dc627dc 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -11,63 +11,64 @@ using Musify.Application.Services; using Musify.Application.Shared; using Musify.Domain.ValueObjects; -namespace Musify.Application.Albums; - -public record UpdateAlbumCommand( - long UserId, - Guid AlbumId, - string Title, - string? Description, - int? ReleaseYear, - Guid? NewPictureIntentId) - : ICommand>; - -public class UpdateAlbumCommandHandler( - IEventBus eventBus, - IDatabase database, - UploadIntentValidator uploadIntentValidator, - ILogger logger, - AlbumConfiguration albumConfiguration) - : ICommandHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) + public record UpdateAlbumCommand( + long UserId, + Guid AlbumId, + string Title, + string? Description, + int? ReleaseYear, + Guid? NewPictureIntentId) + : ICommand>; + + public class UpdateAlbumCommandHandler( + IEventBus eventBus, + IDatabase database, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + AlbumConfiguration albumConfiguration) + : ICommandHandler> { - var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); - if (found.IsError) - return found.Errors; - - var album = found.Value; - var title = request.Title.Trim(); - - album.Title = title; - album.NormalizedTitle = TextNormalizer.Normalize(title); - album.Description = request.Description; - album.ReleaseYear = request.ReleaseYear; - - if (request.NewPictureIntentId is { } intentId) + public async ValueTask> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) { - var prepared = await PictureSourceChange.PrepareAsync( - uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); - if (prepared.IsError) - return prepared.Errors; + var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; - var picture = prepared.Value; - album.Pictures = picture.Pictures; + var album = found.Value; + var title = request.Title.Trim(); - await eventBus.PublishAsync( - new UpdateAlbumPictureSourceEvent( - album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), - cancellationToken); + album.Title = title; + album.NormalizedTitle = TextNormalizer.Normalize(title); + album.Description = request.Description; + album.ReleaseYear = request.ReleaseYear; + + if (request.NewPictureIntentId is { } intentId) + { + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; + + var picture = prepared.Value; + album.Pictures = picture.Pictures; + + await eventBus.PublishAsync( + new UpdateAlbumPictureSourceEvent( + album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), + cancellationToken); + } + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Updated album {AlbumId}", album.Id); + + return await database.Albums + .AsNoTracking() + .Where(a => a.Id == album.Id) + .SelectResponse() + .SingleAsync(cancellationToken); } - - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("Updated album {AlbumId}", album.Id); - - return await database.Albums - .AsNoTracking() - .Where(a => a.Id == album.Id) - .SelectResponse() - .SingleAsync(cancellationToken); } } diff --git a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs index 8e22f82..a96334a 100644 --- a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs +++ b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs @@ -3,26 +3,27 @@ using Microsoft.Extensions.DependencyInjection; using Musify.Application.Configuration; using Musify.Application.Services; -namespace Musify.Application; - -public static class ApplicationDependencyInjection +namespace Musify.Application { - public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration) + public static class ApplicationDependencyInjection { - services.AddMediator(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration) + { + services.AddMediator(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); - services.AddValidatedOptions(configuration); - services.AddValidatedOptions(configuration); - services.AddValidatedOptions(configuration); - services.AddValidatedOptions(configuration); - services.AddValidatedOptions(configuration); - services.AddValidatedOptions(configuration); - services.AddValidatedOptions(configuration); - services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); - return services; + return services; + } } } diff --git a/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs b/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs index c139a4d..88db57b 100644 --- a/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs @@ -1,14 +1,15 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration; - -public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration +namespace Musify.Application.Configuration { - public static string SectionName => "Album"; + public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration + { + public static string SectionName => "Album"; - [Required] - public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" }; + [Required] + public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" }; - [Required] - public PictureSizes PicturesSizes { get; set; } = new(); + [Required] + public PictureSizes PicturesSizes { get; set; } = new(); + } } diff --git a/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs b/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs index f6fedc4..199cc5e 100644 --- a/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs @@ -1,12 +1,13 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration; - -public class ApplicationStorageConfiguration : IConfigurationOptions +namespace Musify.Application.Configuration { - public static string SectionName => "ApplicationStorage"; + public class ApplicationStorageConfiguration : IConfigurationOptions + { + public static string SectionName => "ApplicationStorage"; - [Required] - [StringLength(128, MinimumLength = 1)] - public required string Bucket { get; set; } + [Required] + [StringLength(128, MinimumLength = 1)] + public required string Bucket { get; set; } + } } diff --git a/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs b/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs index aa7fa1a..ff58e2f 100644 --- a/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs +++ b/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs @@ -2,28 +2,29 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -namespace Musify.Application.Configuration; - -public static class ConfigurationDependencyInjection +namespace Musify.Application.Configuration { - public static IServiceCollection AddValidatedOptions(this IServiceCollection services, IConfiguration configuration) - where T : class, IConfigurationOptions => - services.AddValidatedOptions(configuration, T.SectionName); - - public static IServiceCollection AddValidatedOptions( - this IServiceCollection services, - IConfiguration configuration, - string sectionName) - where T : class + public static class ConfigurationDependencyInjection { - services - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(sectionName)) - .ValidateDataAnnotations(); + public static IServiceCollection AddValidatedOptions(this IServiceCollection services, IConfiguration configuration) + where T : class, IConfigurationOptions => + services.AddValidatedOptions(configuration, T.SectionName); - services.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); + public static IServiceCollection AddValidatedOptions( + this IServiceCollection services, + IConfiguration configuration, + string sectionName) + where T : class + { + services + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(sectionName)) + .ValidateDataAnnotations(); - return services; + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + return services; + } } } diff --git a/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs b/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs index e731ae2..8b96121 100644 --- a/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs +++ b/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs @@ -1,16 +1,17 @@ using Mediator; using Musify.Application.Configuration.Responses; -namespace Musify.Application.Configuration; - - -public record GetPlaybackPublicConfigQuery : IQuery; - -public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration) - : IQueryHandler +namespace Musify.Application.Configuration { - public ValueTask Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) => - ValueTask.FromResult(new PlaybackPublicConfigResponse( - playbackConfiguration.AllowAnonymousListening, - playbackConfiguration.AnonymousFragmentSeconds)); + + public record GetPlaybackPublicConfigQuery : IQuery; + + public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration) + : IQueryHandler + { + public ValueTask Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) => + ValueTask.FromResult(new PlaybackPublicConfigResponse( + playbackConfiguration.AllowAnonymousListening, + playbackConfiguration.AnonymousFragmentSeconds)); + } } diff --git a/backend/Core/Musify.Application/Configuration/IConfigurationOptions.cs b/backend/Core/Musify.Application/Configuration/IConfigurationOptions.cs index 3864320..8bd3ccf 100644 --- a/backend/Core/Musify.Application/Configuration/IConfigurationOptions.cs +++ b/backend/Core/Musify.Application/Configuration/IConfigurationOptions.cs @@ -1,6 +1,7 @@ -namespace Musify.Application.Configuration; - -public interface IConfigurationOptions +namespace Musify.Application.Configuration { - public static abstract string SectionName { get; } + public interface IConfigurationOptions + { + public static abstract string SectionName { get; } + } } diff --git a/backend/Core/Musify.Application/Configuration/MixConfiguration.cs b/backend/Core/Musify.Application/Configuration/MixConfiguration.cs index bb16860..1a71d6e 100644 --- a/backend/Core/Musify.Application/Configuration/MixConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/MixConfiguration.cs @@ -1,11 +1,12 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration; - -public class MixConfiguration : IConfigurationOptions +namespace Musify.Application.Configuration { - public static string SectionName => "Mix"; + public class MixConfiguration : IConfigurationOptions + { + public static string SectionName => "Mix"; - [Range(4, 100)] - public int ItemsPerMix { get; set; } = 12; + [Range(4, 100)] + public int ItemsPerMix { get; set; } = 12; + } } diff --git a/backend/Core/Musify.Application/Configuration/Pictures.cs b/backend/Core/Musify.Application/Configuration/Pictures.cs index 5e71035..08235a1 100644 --- a/backend/Core/Musify.Application/Configuration/Pictures.cs +++ b/backend/Core/Musify.Application/Configuration/Pictures.cs @@ -2,90 +2,91 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; using Musify.Application.Shared; -namespace Musify.Application.Configuration; - -public enum PictureSize +namespace Musify.Application.Configuration { - Small, - Medium, - Large -} - -public class PictureRoutes -{ - [Required] - public string UploadsFolder { get; set; } = "uploads"; - - [Required] - public string ParentFolder { get; set; } = string.Empty; - - [Required] - public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; - - [Required] - public string SmallPicturesFolder { get; set; } = "SmallPictures"; - - [Required] - public string MediumPicturesFolder { get; set; } = "MediumPictures"; - - [Required] - public string LargePicturesFolder { get; set; } = "LargePictures"; - - public string FolderPath(PictureSize size) => StorageKey.Combine(ParentFolder, size switch + public enum PictureSize { - PictureSize.Small => SmallPicturesFolder, - PictureSize.Medium => MediumPicturesFolder, - PictureSize.Large => LargePicturesFolder, - _ => throw new ArgumentOutOfRangeException(nameof(size), size, null) - }); + Small, + Medium, + Large + } - public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName); + public class PictureRoutes + { + [Required] + public string UploadsFolder { get; set; } = "uploads"; - public string BuildOriginalPicturePath(long userId, string pictureName) => - StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalPicturesFolder, pictureName); + [Required] + public string ParentFolder { get; set; } = string.Empty; - public string BuildTempPath(string tempRootPrefix, long userId, string objectName) => - StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, objectName); -} - -public class PictureSizes -{ - [Range(1, 1024)] - public int SmallWidth { get; set; } = 128; - - [Range(1, 1024)] - public int SmallHeight { get; set; } = 128; - - [Range(1, 1024)] - public int MediumWidth { get; set; } = 256; - - [Range(1, 1024)] - public int MediumHeight { get; set; } = 256; - - [Range(1, 1024)] - public int LargeWidth { get; set; } = 512; - - [Range(1, 1024)] - public int LargeHeight { get; set; } = 512; - - public ImageSizes ToImageSizes(PictureRoutes routes) => new( - new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight), - new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight), - new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight)); -} - -public interface IPictureOwnerConfiguration -{ - public PictureRoutes Routes { get; } - - public PictureSizes PicturesSizes { get; } -} - -public static class PictureSizeParser -{ - /// Parses the size query value; anything unknown means . - public static PictureSize Parse(string? value) => - Enum.TryParse(value, ignoreCase: true, out var size) && Enum.IsDefined(size) - ? size - : PictureSize.Medium; + [Required] + public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; + + [Required] + public string SmallPicturesFolder { get; set; } = "SmallPictures"; + + [Required] + public string MediumPicturesFolder { get; set; } = "MediumPictures"; + + [Required] + public string LargePicturesFolder { get; set; } = "LargePictures"; + + public string FolderPath(PictureSize size) => StorageKey.Combine(ParentFolder, size switch + { + PictureSize.Small => SmallPicturesFolder, + PictureSize.Medium => MediumPicturesFolder, + PictureSize.Large => LargePicturesFolder, + _ => throw new ArgumentOutOfRangeException(nameof(size), size, null) + }); + + public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName); + + public string BuildOriginalPicturePath(long userId, string pictureName) => + StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalPicturesFolder, pictureName); + + public string BuildTempPath(string tempRootPrefix, long userId, string objectName) => + StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, objectName); + } + + public class PictureSizes + { + [Range(1, 1024)] + public int SmallWidth { get; set; } = 128; + + [Range(1, 1024)] + public int SmallHeight { get; set; } = 128; + + [Range(1, 1024)] + public int MediumWidth { get; set; } = 256; + + [Range(1, 1024)] + public int MediumHeight { get; set; } = 256; + + [Range(1, 1024)] + public int LargeWidth { get; set; } = 512; + + [Range(1, 1024)] + public int LargeHeight { get; set; } = 512; + + public ImageSizes ToImageSizes(PictureRoutes routes) => new( + new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight), + new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight), + new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight)); + } + + public interface IPictureOwnerConfiguration + { + public PictureRoutes Routes { get; } + + public PictureSizes PicturesSizes { get; } + } + + public static class PictureSizeParser + { + /// Parses the size query value; anything unknown means . + public static PictureSize Parse(string? value) => + Enum.TryParse(value, ignoreCase: true, out var size) && Enum.IsDefined(size) + ? size + : PictureSize.Medium; + } } diff --git a/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs b/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs index 10385a9..7550d1e 100644 --- a/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs @@ -1,14 +1,15 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration; - -public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration +namespace Musify.Application.Configuration { - public static string SectionName => "PlayList"; + public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration + { + public static string SectionName => "PlayList"; - [Required] - public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" }; + [Required] + public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" }; - [Required] - public PictureSizes PicturesSizes { get; set; } = new(); + [Required] + public PictureSizes PicturesSizes { get; set; } = new(); + } } diff --git a/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs b/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs index 2fac12b..bf0f0c2 100644 --- a/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs @@ -1,17 +1,18 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration; - - -public sealed class PlaybackConfiguration : IConfigurationOptions +namespace Musify.Application.Configuration { - public static string SectionName => "Playback"; - public bool AllowAnonymousListening { get; set; } + public sealed class PlaybackConfiguration : IConfigurationOptions + { + public static string SectionName => "Playback"; - [Range(0, 3600)] - public int AnonymousFragmentSeconds { get; set; } + public bool AllowAnonymousListening { get; set; } - [Range(1, int.MaxValue)] - public int EstimatedAudioBytesPerSecond { get; set; } = 16_000; + [Range(0, 3600)] + public int AnonymousFragmentSeconds { get; set; } + + [Range(1, int.MaxValue)] + public int EstimatedAudioBytesPerSecond { get; set; } = 16_000; + } } diff --git a/backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs b/backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs index c1083a3..90a92b9 100644 --- a/backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs +++ b/backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs @@ -1,4 +1,5 @@ -namespace Musify.Application.Configuration.Responses; +namespace Musify.Application.Configuration.Responses +{ - -public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds); + public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds); +} diff --git a/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs b/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs index 3c9ecb8..488536d 100644 --- a/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs @@ -1,15 +1,16 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration; - -public class StreamGatewayConfiguration : IConfigurationOptions +namespace Musify.Application.Configuration { - public static string SectionName => "StreamGateway"; + public class StreamGatewayConfiguration : IConfigurationOptions + { + public static string SectionName => "StreamGateway"; - [Url] - [Required] - public required string PublicBaseUrl { get; set; } + [Url] + [Required] + public required string PublicBaseUrl { get; set; } - [Required] - public string AudioFileName { get; set; } = "audio.m4a"; + [Required] + public string AudioFileName { get; set; } = "audio.m4a"; + } } diff --git a/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs b/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs index 80b8df3..b569b1f 100644 --- a/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs @@ -2,55 +2,56 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; using Musify.Application.Shared; -namespace Musify.Application.Configuration; - -public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration +namespace Musify.Application.Configuration { - public static string SectionName => "Track"; - - [Required] - public TrackRoutes Routes { get; set; } = new(); - - [Required] - public PictureSizes PicturesSizes { get; set; } = new(); - - PictureRoutes IPictureOwnerConfiguration.Routes => Routes; -} - -public class TrackRoutes : PictureRoutes -{ - public TrackRoutes() + public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration { - ParentFolder = "Tracks"; + public static string SectionName => "Track"; + + [Required] + public TrackRoutes Routes { get; set; } = new(); + + [Required] + public PictureSizes PicturesSizes { get; set; } = new(); + + PictureRoutes IPictureOwnerConfiguration.Routes => Routes; } - [Required] - public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp"; + public class TrackRoutes : PictureRoutes + { + public TrackRoutes() + { + ParentFolder = "Tracks"; + } - [Required] - public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp"; + [Required] + public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp"; - [Required] - public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp"; + [Required] + public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp"; - [Required] - public string PresetLargePicture { get; set; } = "PresetLargePicture.webp"; + [Required] + public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp"; - [Required] - public string OriginalAudiosFolder { get; set; } = "OriginalAudios"; + [Required] + public string PresetLargePicture { get; set; } = "PresetLargePicture.webp"; - [Required] - public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios"; + [Required] + public string OriginalAudiosFolder { get; set; } = "OriginalAudios"; - public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture); + [Required] + public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios"; - public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture); + public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture); - public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture); + public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture); - public string BuildOriginalAudioPath(long userId, string audioName) => - StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalAudiosFolder, audioName); + public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture); - public string BuildProcessedAudioPath(string folderName) => - StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName); + public string BuildOriginalAudioPath(long userId, string audioName) => + StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalAudiosFolder, audioName); + + public string BuildProcessedAudioPath(string folderName) => + StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName); + } } diff --git a/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs b/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs index 3b72185..eb68c90 100644 --- a/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs @@ -1,41 +1,42 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration; - -public class UploadIntentConfiguration : IConfigurationOptions +namespace Musify.Application.Configuration { - public static string SectionName => "UploadIntent"; + public class UploadIntentConfiguration : IConfigurationOptions + { + public static string SectionName => "UploadIntent"; - [Range(10, 3600)] - public int UploadUrlExpiresInSeconds { get; set; } = 120; + [Range(10, 3600)] + public int UploadUrlExpiresInSeconds { get; set; } = 120; - [Range(1, 100)] - public int MaxActiveUploadIntentsPerUser { get; set; } = 5; + [Range(1, 100)] + public int MaxActiveUploadIntentsPerUser { get; set; } = 5; - [Range(1, long.MaxValue)] - public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024; + [Range(1, long.MaxValue)] + public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024; - [Range(1, long.MaxValue)] - public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024; + [Range(1, long.MaxValue)] + public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024; - [Range(1, long.MaxValue)] - public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024; + [Range(1, long.MaxValue)] + public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024; - [Range(1, long.MaxValue)] - public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024; + [Range(1, long.MaxValue)] + public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024; - [Range(30, 86400)] - public int ExpirationJobIntervalSeconds { get; set; } = 300; + [Range(30, 86400)] + public int ExpirationJobIntervalSeconds { get; set; } = 300; - [Range(0, 365)] - public int ExpiredIntentsRetentionDays { get; set; } = 7; + [Range(0, 365)] + public int ExpiredIntentsRetentionDays { get; set; } = 7; - [Required, MinLength(1)] - public string TempRootPrefix { get; set; } = "temp"; + [Required, MinLength(1)] + public string TempRootPrefix { get; set; } = "temp"; - [Range(1, 30)] - public int TempUploadsRetentionDays { get; set; } = 3; + [Range(1, 30)] + public int TempUploadsRetentionDays { get; set; } = 3; - [Range(60, 86400)] - public int TempCleanupJobIntervalSeconds { get; set; } = 3600; + [Range(60, 86400)] + public int TempCleanupJobIntervalSeconds { get; set; } = 3600; + } } diff --git a/backend/Core/Musify.Application/Contracts/AudioTranscodeResult.cs b/backend/Core/Musify.Application/Contracts/AudioTranscodeResult.cs index 85e45e4..43902f1 100644 --- a/backend/Core/Musify.Application/Contracts/AudioTranscodeResult.cs +++ b/backend/Core/Musify.Application/Contracts/AudioTranscodeResult.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Contracts; - -public record AudioTranscodeResult(int StatusCode, TimeSpan Duration); +namespace Musify.Application.Contracts +{ + public record AudioTranscodeResult(int StatusCode, TimeSpan Duration); +} diff --git a/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs b/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs index 29f8b81..e4f56a0 100644 --- a/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs +++ b/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs @@ -1,6 +1,7 @@ -namespace Musify.Application.Contracts; - -public interface IAudioTranscoderService +namespace Musify.Application.Contracts { - public Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken); + public interface IAudioTranscoderService + { + public Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken); + } } diff --git a/backend/Core/Musify.Application/Contracts/IDatabase.cs b/backend/Core/Musify.Application/Contracts/IDatabase.cs index 89e0964..840a894 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabase.cs @@ -2,41 +2,42 @@ using ErrorOr; using Microsoft.EntityFrameworkCore; using Musify.Domain.Entities; -namespace Musify.Application.Contracts; - -public interface IDatabase +namespace Musify.Application.Contracts { - public DbSet Users { get; } + public interface IDatabase + { + public DbSet Users { get; } - public DbSet PlayLists { get; } + public DbSet PlayLists { get; } - public DbSet Albums { get; } + public DbSet Albums { get; } - public DbSet AlbumHasTracks { get; } + public DbSet AlbumHasTracks { get; } - public DbSet Tracks { get; } + public DbSet Tracks { get; } - public DbSet PlayListHasTracks { get; } + public DbSet PlayListHasTracks { get; } - public DbSet Mixes { get; } + public DbSet Mixes { get; } - public DbSet MixItems { get; } + public DbSet MixItems { get; } - public DbSet UploadIntents { get; } + public DbSet UploadIntents { get; } - public DbSet ListeningHistories { get; } + public DbSet ListeningHistories { get; } - public DbSet TrackLikes { get; } + public DbSet TrackLikes { get; } - public DbSet TrackTags { get; } + public DbSet TrackTags { get; } - public DbSet UserFollows { get; } + public DbSet UserFollows { get; } - public Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); + public Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); - public DbSet Set() where TEntity : class; + public DbSet Set() where TEntity : class; - public Task SaveChangesAsync(CancellationToken cancellationToken); + public Task SaveChangesAsync(CancellationToken cancellationToken); - public Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken); + public Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken); + } } diff --git a/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs b/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs index 5e71827..1026ebb 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs @@ -1,6 +1,7 @@ -namespace Musify.Application.Contracts; - -public interface IDatabaseTransaction : IAsyncDisposable +namespace Musify.Application.Contracts { - public Task CommitAsync(CancellationToken cancellationToken = default); + public interface IDatabaseTransaction : IAsyncDisposable + { + public Task CommitAsync(CancellationToken cancellationToken = default); + } } diff --git a/backend/Core/Musify.Application/Contracts/IEventBus.cs b/backend/Core/Musify.Application/Contracts/IEventBus.cs index bd9182a..0f26bc5 100644 --- a/backend/Core/Musify.Application/Contracts/IEventBus.cs +++ b/backend/Core/Musify.Application/Contracts/IEventBus.cs @@ -1,6 +1,7 @@ -namespace Musify.Application.Contracts; - -public interface IEventBus +namespace Musify.Application.Contracts { - public Task PublishAsync(T message, CancellationToken cancellationToken) where T : class; + public interface IEventBus + { + public Task PublishAsync(T message, CancellationToken cancellationToken) where T : class; + } } diff --git a/backend/Core/Musify.Application/Contracts/IPictureService.cs b/backend/Core/Musify.Application/Contracts/IPictureService.cs index 3e74bf4..b5958d8 100644 --- a/backend/Core/Musify.Application/Contracts/IPictureService.cs +++ b/backend/Core/Musify.Application/Contracts/IPictureService.cs @@ -1,6 +1,7 @@ -namespace Musify.Application.Contracts; - -public interface IPictureService +namespace Musify.Application.Contracts { - public Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken); + public interface IPictureService + { + public Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken); + } } diff --git a/backend/Core/Musify.Application/Contracts/IStorageService.cs b/backend/Core/Musify.Application/Contracts/IStorageService.cs index 90a8601..dfd37ab 100644 --- a/backend/Core/Musify.Application/Contracts/IStorageService.cs +++ b/backend/Core/Musify.Application/Contracts/IStorageService.cs @@ -1,24 +1,25 @@ -namespace Musify.Application.Contracts; - -public interface IStorageService +namespace Musify.Application.Contracts { - /// Opens an object for reading, or returns null when it does not exist. - public Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); + public interface IStorageService + { + /// Opens an object for reading, or returns null when it does not exist. + public Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); - public Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); + public Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); - public Task GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default); + public Task GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default); - public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken); + public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken); - public Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken); + public Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken); - public Task> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken); + public Task> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken); - public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken); + public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken); - public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, - CancellationToken cancellationToken); + public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, + CancellationToken cancellationToken); - public IAsyncEnumerable ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken); + public IAsyncEnumerable ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken); + } } diff --git a/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs b/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs index 2668555..c4d9118 100644 --- a/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs +++ b/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs @@ -1,8 +1,9 @@ -namespace Musify.Application.Contracts; - -public interface IStreamTicketService +namespace Musify.Application.Contracts { - public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null); -} + public interface IStreamTicketService + { + public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null); + } -public record StreamTicket(string Token, int ExpiresInSeconds); + public record StreamTicket(string Token, int ExpiresInSeconds); +} diff --git a/backend/Core/Musify.Application/Contracts/ObjectMetaData.cs b/backend/Core/Musify.Application/Contracts/ObjectMetaData.cs index 0211261..6406463 100644 --- a/backend/Core/Musify.Application/Contracts/ObjectMetaData.cs +++ b/backend/Core/Musify.Application/Contracts/ObjectMetaData.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Contracts; - -public record ObjectMetaData(string ContentType, long ContentLength); +namespace Musify.Application.Contracts +{ + public record ObjectMetaData(string ContentType, long ContentLength); +} diff --git a/backend/Core/Musify.Application/Contracts/StorageObject.cs b/backend/Core/Musify.Application/Contracts/StorageObject.cs index 9572137..2ec4d45 100644 --- a/backend/Core/Musify.Application/Contracts/StorageObject.cs +++ b/backend/Core/Musify.Application/Contracts/StorageObject.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Contracts; - -public record StorageObject(string Key, DateTime LastModifiedUtc); +namespace Musify.Application.Contracts +{ + public record StorageObject(string Key, DateTime LastModifiedUtc); +} diff --git a/backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs b/backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs index 99b2cb6..73db1d3 100644 --- a/backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs +++ b/backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Events; - -public record AlbumPictureProcessed(Guid AlbumId); +namespace Musify.Application.Events +{ + public record AlbumPictureProcessed(Guid AlbumId); +} diff --git a/backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs b/backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs index f659536..24f4cc5 100644 --- a/backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Events; - -public record AlbumPictureProcessingFailed(Guid AlbumId); +namespace Musify.Application.Events +{ + public record AlbumPictureProcessingFailed(Guid AlbumId); +} diff --git a/backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs b/backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs index 4640faf..5ee8895 100644 --- a/backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs @@ -1,6 +1,7 @@ -namespace Musify.Application.Events; - -public record AlbumProcessingFailed( - Guid AlbumId, - string? Bucket, - string? PictureKey); +namespace Musify.Application.Events +{ + public record AlbumProcessingFailed( + Guid AlbumId, + string? Bucket, + string? PictureKey); +} diff --git a/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs index 01dada3..f048259 100644 --- a/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs @@ -1,11 +1,12 @@ using Musify.Application.Shared; -namespace Musify.Application.Events; - -public record CreateAlbumResourcesEvent( - Guid AlbumId, - Guid PictureIntentId, - string Bucket, - string PictureSourceKey, - string PictureDestinationKey, - ImageSizes Sizes); +namespace Musify.Application.Events +{ + public record CreateAlbumResourcesEvent( + Guid AlbumId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs index 9ad6955..faf34b9 100644 --- a/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs @@ -1,11 +1,12 @@ using Musify.Application.Shared; -namespace Musify.Application.Events; - -public record CreatePlayListResourcesEvent( - Guid PlayListId, - Guid PictureIntentId, - string Bucket, - string PictureSourceKey, - string PictureDestinationKey, - ImageSizes Sizes); +namespace Musify.Application.Events +{ + public record CreatePlayListResourcesEvent( + Guid PlayListId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs index baa9315..6a78fd5 100644 --- a/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs @@ -1,15 +1,16 @@ using Musify.Application.Shared; -namespace Musify.Application.Events; - -public record CreateTrackResourcesEvent( - Guid TrackId, - Guid PictureIntentId, - Guid AudioIntentId, - string Bucket, - string PictureSourceKey, - string PictureDestinationKey, - string AudioSourceKey, - string AudioDestinationKey, - string AudioProcessedFolderKey, - ImageSizes Sizes); +namespace Musify.Application.Events +{ + public record CreateTrackResourcesEvent( + Guid TrackId, + Guid PictureIntentId, + Guid AudioIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + string AudioSourceKey, + string AudioDestinationKey, + string AudioProcessedFolderKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs b/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs index ab5cfd5..ebe52d0 100644 --- a/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs +++ b/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs @@ -1,5 +1,6 @@ -namespace Musify.Application.Events; - -public record DeleteAlbumEvent( - Guid AlbumId, - long UserId); +namespace Musify.Application.Events +{ + public record DeleteAlbumEvent( + Guid AlbumId, + long UserId); +} diff --git a/backend/Core/Musify.Application/Events/DeletePlayListEvent.cs b/backend/Core/Musify.Application/Events/DeletePlayListEvent.cs index 40d589b..06a7a2c 100644 --- a/backend/Core/Musify.Application/Events/DeletePlayListEvent.cs +++ b/backend/Core/Musify.Application/Events/DeletePlayListEvent.cs @@ -1,5 +1,6 @@ -namespace Musify.Application.Events; - -public record DeletePlayListEvent( - Guid PlayListId, - long UserId); +namespace Musify.Application.Events +{ + public record DeletePlayListEvent( + Guid PlayListId, + long UserId); +} diff --git a/backend/Core/Musify.Application/Events/DeleteTrackEvent.cs b/backend/Core/Musify.Application/Events/DeleteTrackEvent.cs index 7c78c6f..8fb6923 100644 --- a/backend/Core/Musify.Application/Events/DeleteTrackEvent.cs +++ b/backend/Core/Musify.Application/Events/DeleteTrackEvent.cs @@ -1,5 +1,6 @@ -namespace Musify.Application.Events; - -public record DeleteTrackEvent( - Guid TrackId, - long UserId); +namespace Musify.Application.Events +{ + public record DeleteTrackEvent( + Guid TrackId, + long UserId); +} diff --git a/backend/Core/Musify.Application/Events/PlayListPictureProcessed.cs b/backend/Core/Musify.Application/Events/PlayListPictureProcessed.cs index ddb68e7..74f03f2 100644 --- a/backend/Core/Musify.Application/Events/PlayListPictureProcessed.cs +++ b/backend/Core/Musify.Application/Events/PlayListPictureProcessed.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Events; - -public record PlayListPictureProcessed(Guid PlayListId); +namespace Musify.Application.Events +{ + public record PlayListPictureProcessed(Guid PlayListId); +} diff --git a/backend/Core/Musify.Application/Events/PlayListPictureProcessingFailed.cs b/backend/Core/Musify.Application/Events/PlayListPictureProcessingFailed.cs index 01e0b9c..661932c 100644 --- a/backend/Core/Musify.Application/Events/PlayListPictureProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/PlayListPictureProcessingFailed.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Events; - -public record PlayListPictureProcessingFailed(Guid PlayListId); +namespace Musify.Application.Events +{ + public record PlayListPictureProcessingFailed(Guid PlayListId); +} diff --git a/backend/Core/Musify.Application/Events/PlayListProcessingFailed.cs b/backend/Core/Musify.Application/Events/PlayListProcessingFailed.cs index 6c97a28..50a5054 100644 --- a/backend/Core/Musify.Application/Events/PlayListProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/PlayListProcessingFailed.cs @@ -1,6 +1,7 @@ -namespace Musify.Application.Events; - -public record PlayListProcessingFailed( - Guid PlayListId, - string? Bucket, - string? PictureKey); +namespace Musify.Application.Events +{ + public record PlayListProcessingFailed( + Guid PlayListId, + string? Bucket, + string? PictureKey); +} diff --git a/backend/Core/Musify.Application/Events/TrackAudioProcessed.cs b/backend/Core/Musify.Application/Events/TrackAudioProcessed.cs index 3bc35a3..9648bf2 100644 --- a/backend/Core/Musify.Application/Events/TrackAudioProcessed.cs +++ b/backend/Core/Musify.Application/Events/TrackAudioProcessed.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Events; - -public record TrackAudioProcessed(Guid TrackId); +namespace Musify.Application.Events +{ + public record TrackAudioProcessed(Guid TrackId); +} diff --git a/backend/Core/Musify.Application/Events/TrackAudioProcessingFailed.cs b/backend/Core/Musify.Application/Events/TrackAudioProcessingFailed.cs index 2757cdd..c531163 100644 --- a/backend/Core/Musify.Application/Events/TrackAudioProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/TrackAudioProcessingFailed.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Events; - -public record TrackAudioProcessingFailed(Guid TrackId); +namespace Musify.Application.Events +{ + public record TrackAudioProcessingFailed(Guid TrackId); +} diff --git a/backend/Core/Musify.Application/Events/TrackPictureProcessed.cs b/backend/Core/Musify.Application/Events/TrackPictureProcessed.cs index 0c39420..e42b30f 100644 --- a/backend/Core/Musify.Application/Events/TrackPictureProcessed.cs +++ b/backend/Core/Musify.Application/Events/TrackPictureProcessed.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Events; - -public record TrackPictureProcessed(Guid TrackId); +namespace Musify.Application.Events +{ + public record TrackPictureProcessed(Guid TrackId); +} diff --git a/backend/Core/Musify.Application/Events/TrackPictureProcessingFailed.cs b/backend/Core/Musify.Application/Events/TrackPictureProcessingFailed.cs index 593253a..df08dec 100644 --- a/backend/Core/Musify.Application/Events/TrackPictureProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/TrackPictureProcessingFailed.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Events; - -public record TrackPictureProcessingFailed(Guid TrackId); +namespace Musify.Application.Events +{ + public record TrackPictureProcessingFailed(Guid TrackId); +} diff --git a/backend/Core/Musify.Application/Events/TrackProcessingFailed.cs b/backend/Core/Musify.Application/Events/TrackProcessingFailed.cs index c7fb5c5..5234fe6 100644 --- a/backend/Core/Musify.Application/Events/TrackProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/TrackProcessingFailed.cs @@ -1,7 +1,8 @@ -namespace Musify.Application.Events; - -public record TrackProcessingFailed( - Guid TrackId, - string? Bucket, - string? PictureKey, - string? AudioKey); +namespace Musify.Application.Events +{ + public record TrackProcessingFailed( + Guid TrackId, + string? Bucket, + string? PictureKey, + string? AudioKey); +} diff --git a/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs index 6c8f43e..6ec428d 100644 --- a/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs @@ -1,9 +1,10 @@ using Musify.Application.Shared; -namespace Musify.Application.Events; - -public record UpdateAlbumPictureEvent( - Guid AlbumId, - string Bucket, - string SourceKey, - ImageSizes Sizes); +namespace Musify.Application.Events +{ + public record UpdateAlbumPictureEvent( + Guid AlbumId, + string Bucket, + string SourceKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs b/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs index 8fa33b8..92d727e 100644 --- a/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs @@ -1,11 +1,12 @@ using Musify.Application.Shared; -namespace Musify.Application.Events; - -public record UpdateAlbumPictureSourceEvent( - Guid AlbumId, - Guid PictureIntentId, - string Bucket, - string PictureSourceKey, - string PictureDestinationKey, - ImageSizes Sizes); +namespace Musify.Application.Events +{ + public record UpdateAlbumPictureSourceEvent( + Guid AlbumId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs index 1b2d646..e28110d 100644 --- a/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs @@ -1,9 +1,10 @@ using Musify.Application.Shared; -namespace Musify.Application.Events; - -public record UpdatePlayListPictureEvent( - Guid PlayListId, - string Bucket, - string SourceKey, - ImageSizes Sizes); +namespace Musify.Application.Events +{ + public record UpdatePlayListPictureEvent( + Guid PlayListId, + string Bucket, + string SourceKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs b/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs index bba5c34..a5096fd 100644 --- a/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs @@ -1,11 +1,12 @@ using Musify.Application.Shared; -namespace Musify.Application.Events; - -public record UpdatePlayListPictureSourceEvent( - Guid PlayListId, - Guid PictureIntentId, - string Bucket, - string PictureSourceKey, - string PictureDestinationKey, - ImageSizes Sizes); +namespace Musify.Application.Events +{ + public record UpdatePlayListPictureSourceEvent( + Guid PlayListId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs b/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs index a3b6b60..e1bc155 100644 --- a/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs @@ -1,8 +1,9 @@ -namespace Musify.Application.Events; - -public record UpdateTrackAudioEvent( - Guid TrackId, - string SourceBucket, - string SourceKey, - string DestinationBucket, - string DestinationFolderKey); +namespace Musify.Application.Events +{ + public record UpdateTrackAudioEvent( + Guid TrackId, + string SourceBucket, + string SourceKey, + string DestinationBucket, + string DestinationFolderKey); +} diff --git a/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs index 0c83c9e..e7ca8f4 100644 --- a/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs @@ -1,9 +1,10 @@ using Musify.Application.Shared; -namespace Musify.Application.Events; - -public record UpdateTrackPictureEvent( - Guid TrackId, - string Bucket, - string SourceKey, - ImageSizes Sizes); +namespace Musify.Application.Events +{ + public record UpdateTrackPictureEvent( + Guid TrackId, + string Bucket, + string SourceKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs index 694c9cd..f96eb11 100644 --- a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs @@ -2,23 +2,24 @@ using Mediator; using Musify.Application.Genres.Responses; using Musify.Domain.ValueObjects; -namespace Musify.Application.Genres; - -public record GetAvailableGenresQuery : IQuery>; - -public class GetAvailableGenresQueryHandler - : IQueryHandler> +namespace Musify.Application.Genres { - public ValueTask> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken) + public record GetAvailableGenresQuery : IQuery>; + + public class GetAvailableGenresQueryHandler + : IQueryHandler> { - var all = Enum.GetValues(); + public ValueTask> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken) + { + var all = Enum.GetValues(); - IReadOnlyList result = all - .Select(genre => new AvailableGenreResponse( - genre, - all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList())) - .ToList(); + IReadOnlyList result = all + .Select(genre => new AvailableGenreResponse( + genre, + all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList())) + .ToList(); - return ValueTask.FromResult(result); + return ValueTask.FromResult(result); + } } } diff --git a/backend/Core/Musify.Application/Genres/GetGenres.cs b/backend/Core/Musify.Application/Genres/GetGenres.cs index 6ad2465..680793e 100644 --- a/backend/Core/Musify.Application/Genres/GetGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetGenres.cs @@ -4,26 +4,27 @@ using Musify.Application.Contracts; using Musify.Application.Genres.Responses; using Musify.Domain.ValueObjects; -namespace Musify.Application.Genres; - -public record GetGenresQuery : IQuery>; - -public class GetGenresQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Genres { - public async ValueTask> Handle(GetGenresQuery request, CancellationToken cancellationToken) - { - var rows = await database.TrackTags - .AsNoTracking() - .Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active) - .GroupBy(tag => tag.Tag) - .Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() }) - .ToListAsync(cancellationToken); + public record GetGenresQuery : IQuery>; - return rows - .OrderByDescending(row => row.TrackCount) - .ThenBy(row => row.Genre) - .Select(row => new GenreResponse(row.Genre, row.TrackCount)) - .ToList(); + public class GetGenresQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetGenresQuery request, CancellationToken cancellationToken) + { + var rows = await database.TrackTags + .AsNoTracking() + .Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active) + .GroupBy(tag => tag.Tag) + .Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() }) + .ToListAsync(cancellationToken); + + return rows + .OrderByDescending(row => row.TrackCount) + .ThenBy(row => row.Genre) + .Select(row => new GenreResponse(row.Genre, row.TrackCount)) + .ToList(); + } } } diff --git a/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs b/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs index 2ff905e..85060be 100644 --- a/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs +++ b/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs @@ -1,5 +1,6 @@ using Musify.Domain.ValueObjects; -namespace Musify.Application.Genres.Responses; - -public record AvailableGenreResponse(Genre Genre, IReadOnlyList IncompatibleWith); +namespace Musify.Application.Genres.Responses +{ + public record AvailableGenreResponse(Genre Genre, IReadOnlyList IncompatibleWith); +} diff --git a/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs b/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs index 60ae876..1ffd036 100644 --- a/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs +++ b/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs @@ -1,5 +1,6 @@ using Musify.Domain.ValueObjects; -namespace Musify.Application.Genres.Responses; - -public record GenreResponse(Genre Genre, int TrackCount); +namespace Musify.Application.Genres.Responses +{ + public record GenreResponse(Genre Genre, int TrackCount); +} diff --git a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs index 3087e61..5b4a2b1 100644 --- a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs +++ b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs @@ -5,26 +5,27 @@ using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; -namespace Musify.Application.Likes; - -public record GetLikedTracksQuery( - long UserId, - int PageNumber, - int PageSize) - : IQuery>; - -public class GetLikedTracksQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Likes { - public async ValueTask> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) + public record GetLikedTracksQuery( + long UserId, + int PageNumber, + int PageSize) + : IQuery>; + + public class GetLikedTracksQueryHandler(IDatabase database) + : IQueryHandler> { - return await database.TrackLikes - .AsNoTracking() - .Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active) - .OrderByDescending(like => like.CreatedAt) - .ThenBy(like => like.Id) - .Select(like => like.Track) - .SelectResponse() - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + public async ValueTask> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) + { + return await database.TrackLikes + .AsNoTracking() + .Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active) + .OrderByDescending(like => like.CreatedAt) + .ThenBy(like => like.Id) + .Select(like => like.Track) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs index 352eb0a..95e5978 100644 --- a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs +++ b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs @@ -7,52 +7,53 @@ using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Likes; - -public record ToggleTrackLikeCommand(long UserId, Guid TrackId) - : ICommand>; - -public class ToggleTrackLikeCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.Likes { - public async ValueTask> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken) + public record ToggleTrackLikeCommand(long UserId, Guid TrackId) + : ICommand>; + + public class ToggleTrackLikeCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - var trackExists = await database.Tracks - .AsNoTracking() - .AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); - if (!trackExists) - return AppErrors.NotFound("Track", request.TrackId); - - var existingLike = await database.TrackLikes - .SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken); - - bool nowLiked; - if (existingLike != null) + public async ValueTask> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken) { - database.TrackLikes.Remove(existingLike); - nowLiked = false; - } - else - { - await database.TrackLikes.AddAsync(new TrackLike + var trackExists = await database.Tracks + .AsNoTracking() + .AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); + if (!trackExists) + return AppErrors.NotFound("Track", request.TrackId); + + var existingLike = await database.TrackLikes + .SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken); + + bool nowLiked; + if (existingLike != null) { - UserId = request.UserId, - TrackId = request.TrackId - }, cancellationToken); - nowLiked = true; + database.TrackLikes.Remove(existingLike); + nowLiked = false; + } + else + { + await database.TrackLikes.AddAsync(new TrackLike + { + UserId = request.UserId, + TrackId = request.TrackId + }, cancellationToken); + nowLiked = true; + } + + // A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op. + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken); + if (saved.IsError) + return true; + + logger.LogInformation("User {UserId} {Action} track {TrackId}", + request.UserId, nowLiked ? "liked" : "unliked", request.TrackId); + + return nowLiked; } - - // A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op. - var saved = await database.TrySaveChangesAsync( - AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken); - if (saved.IsError) - return true; - - logger.LogInformation("User {UserId} {Action} track {TrackId}", - request.UserId, nowLiked ? "liked" : "unliked", request.TrackId); - - return nowLiked; } } diff --git a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs index 0bf792f..7cda638 100644 --- a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs +++ b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -6,124 +6,125 @@ using Musify.Application.Contracts; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Mixes; - -public record GenerateMixesForUserCommand(long UserId) - : ICommand; - -public class GenerateMixesForUserCommandHandler( - IDatabase database, - MixConfiguration mixConfiguration, - ILogger logger) - : ICommandHandler +namespace Musify.Application.Mixes { - private sealed record MixDraft(MixKind Kind, IReadOnlyList TrackIds); + public record GenerateMixesForUserCommand(long UserId) + : ICommand; - public async ValueTask Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) + public class GenerateMixesForUserCommandHandler( + IDatabase database, + MixConfiguration mixConfiguration, + ILogger logger) + : ICommandHandler { - var userId = request.UserId; + private sealed record MixDraft(MixKind Kind, IReadOnlyList TrackIds); - var libraryTrackIds = await database.Tracks - .AsNoTracking() - .Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active) - .Select(track => track.Id) - .ToListAsync(cancellationToken); - - if (libraryTrackIds.Count == 0) + public async ValueTask Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) { - logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId); + var userId = request.UserId; + + var libraryTrackIds = await database.Tracks + .AsNoTracking() + .Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => track.Id) + .ToListAsync(cancellationToken); + + if (libraryTrackIds.Count == 0) + { + logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId); + return Unit.Value; + } + + var historyTrackIds = await database.ListeningHistories + .AsNoTracking() + .Where(history => history.UserId == userId && history.IsCounted) + .Select(history => history.TrackId) + .Distinct() + .ToListAsync(cancellationToken); + + var historySet = new HashSet(historyTrackIds); + var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList(); + + var drafts = new List(); + + var discovery = Take(Shuffle(unheardTrackIds)); + if (discovery.Count > 0) + { + drafts.Add(new MixDraft(MixKind.Discovery, discovery)); + } + + var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds))); + if (daily.Count > 0) + { + drafts.Add(new MixDraft(MixKind.Daily, daily)); + } + + if (drafts.Count == 0) + { + logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId); + return Unit.Value; + } + + await ReplaceMixesAsync(userId, drafts, cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId); return Unit.Value; } - var historyTrackIds = await database.ListeningHistories - .AsNoTracking() - .Where(history => history.UserId == userId && history.IsCounted) - .Select(history => history.TrackId) - .Distinct() - .ToListAsync(cancellationToken); + private List Take(IEnumerable trackIds) => + [.. trackIds.Take(mixConfiguration.ItemsPerMix)]; - var historySet = new HashSet(historyTrackIds); - var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList(); + private static List Shuffle(IEnumerable trackIds) => + [.. trackIds.OrderBy(_ => Guid.NewGuid())]; - var drafts = new List(); - - var discovery = Take(Shuffle(unheardTrackIds)); - if (discovery.Count > 0) + private static List Interleave(List first, List second) { - drafts.Add(new MixDraft(MixKind.Discovery, discovery)); - } - - var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds))); - if (daily.Count > 0) - { - drafts.Add(new MixDraft(MixKind.Daily, daily)); - } - - if (drafts.Count == 0) - { - logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId); - return Unit.Value; - } - - await ReplaceMixesAsync(userId, drafts, cancellationToken); - - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId); - return Unit.Value; - } - - private List Take(IEnumerable trackIds) => - [.. trackIds.Take(mixConfiguration.ItemsPerMix)]; - - private static List Shuffle(IEnumerable trackIds) => - [.. trackIds.OrderBy(_ => Guid.NewGuid())]; - - private static List Interleave(List first, List second) - { - var merged = new List(first.Count + second.Count); - for (var index = 0; index < Math.Max(first.Count, second.Count); index++) - { - if (index < first.Count) - merged.Add(first[index]); - if (index < second.Count) - merged.Add(second[index]); - } - - return merged; - } - - private async Task ReplaceMixesAsync(long userId, IReadOnlyList drafts, CancellationToken cancellationToken) - { - var previousMixes = await database.Mixes - .Where(mix => mix.UserId == userId) - .ToListAsync(cancellationToken); - - if (previousMixes.Count > 0) - { - database.Mixes.RemoveRange(previousMixes); - } - - for (var position = 0; position < drafts.Count; position++) - { - var draft = drafts[position]; - var mix = new Mix + var merged = new List(first.Count + second.Count); + for (var index = 0; index < Math.Max(first.Count, second.Count); index++) { - UserId = userId, - Kind = draft.Kind, - Position = position - }; + if (index < first.Count) + merged.Add(first[index]); + if (index < second.Count) + merged.Add(second[index]); + } - await database.Mixes.AddAsync(mix, cancellationToken); + return merged; + } - for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++) + private async Task ReplaceMixesAsync(long userId, IReadOnlyList drafts, CancellationToken cancellationToken) + { + var previousMixes = await database.Mixes + .Where(mix => mix.UserId == userId) + .ToListAsync(cancellationToken); + + if (previousMixes.Count > 0) { - await database.MixItems.AddAsync(new MixItem + database.Mixes.RemoveRange(previousMixes); + } + + for (var position = 0; position < drafts.Count; position++) + { + var draft = drafts[position]; + var mix = new Mix { - MixId = mix.Id, - Position = itemPosition, - TrackId = draft.TrackIds[itemPosition] - }, cancellationToken); + UserId = userId, + Kind = draft.Kind, + Position = position + }; + + await database.Mixes.AddAsync(mix, cancellationToken); + + for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++) + { + await database.MixItems.AddAsync(new MixItem + { + MixId = mix.Id, + Position = itemPosition, + TrackId = draft.TrackIds[itemPosition] + }, cancellationToken); + } } } } diff --git a/backend/Core/Musify.Application/Mixes/GetMixById.cs b/backend/Core/Musify.Application/Mixes/GetMixById.cs index e9e44f1..65c8a9d 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixById.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixById.cs @@ -5,27 +5,28 @@ using Musify.Application.Contracts; using Musify.Application.Mixes.Responses; using Musify.Application.Shared; -namespace Musify.Application.Mixes; - -public record GetMixByIdQuery(long UserId, Guid MixId) - : IQuery>; - -public class GetMixByIdQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Mixes { - public async ValueTask> Handle(GetMixByIdQuery request, CancellationToken cancellationToken) + public record GetMixByIdQuery(long UserId, Guid MixId) + : IQuery>; + + public class GetMixByIdQueryHandler(IDatabase database) + : IQueryHandler> { - var mix = await database.Mixes - .AsNoTracking() - .Include(mix => mix.Items) - .SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken); + public async ValueTask> Handle(GetMixByIdQuery request, CancellationToken cancellationToken) + { + var mix = await database.Mixes + .AsNoTracking() + .Include(mix => mix.Items) + .SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken); - if (mix == null) - return AppErrors.NotFound("Mix", request.MixId); + if (mix == null) + return AppErrors.NotFound("Mix", request.MixId); - var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); - var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken); + var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); + var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken); - return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items); + return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items); + } } } diff --git a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs index 303c41c..73ea1bb 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs @@ -3,48 +3,49 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Mixes.Responses; -namespace Musify.Application.Mixes; - -public record GetMixesByUserIdQuery(long UserId) - : IQuery>; - -public class GetMixesByUserIdQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Mixes { - public async ValueTask> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) + public record GetMixesByUserIdQuery(long UserId) + : IQuery>; + + public class GetMixesByUserIdQueryHandler(IDatabase database) + : IQueryHandler> { - var mixes = await database.Mixes - .AsNoTracking() - .Where(mix => mix.UserId == request.UserId) - .Include(mix => mix.Items) - .OrderBy(mix => mix.Position) - .ThenBy(mix => mix.Id) - .ToListAsync(cancellationToken); - - var orderedItemsByMix = mixes.ToDictionary( - mix => mix.Id, - mix => mix.Items.OrderBy(item => item.Position).ToList()); - - var coverItems = orderedItemsByMix.Values - .SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount)) - .ToList(); - var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken)) - .GroupBy(response => response.TrackId) - .ToDictionary(group => group.Key, group => group.First()); - - var responses = new List(mixes.Count); - foreach (var mix in mixes) + public async ValueTask> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) { - var orderedItems = orderedItemsByMix[mix.Id]; - var covers = orderedItems - .Take(MixApplicationResponse.CoverItemCount) - .Where(item => coverResponses.ContainsKey(item.TrackId)) - .Select(item => coverResponses[item.TrackId]) + var mixes = await database.Mixes + .AsNoTracking() + .Where(mix => mix.UserId == request.UserId) + .Include(mix => mix.Items) + .OrderBy(mix => mix.Position) + .ThenBy(mix => mix.Id) + .ToListAsync(cancellationToken); + + var orderedItemsByMix = mixes.ToDictionary( + mix => mix.Id, + mix => mix.Items.OrderBy(item => item.Position).ToList()); + + var coverItems = orderedItemsByMix.Values + .SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount)) .ToList(); + var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken)) + .GroupBy(response => response.TrackId) + .ToDictionary(group => group.Key, group => group.First()); - responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers)); + var responses = new List(mixes.Count); + foreach (var mix in mixes) + { + var orderedItems = orderedItemsByMix[mix.Id]; + var covers = orderedItems + .Take(MixApplicationResponse.CoverItemCount) + .Where(item => coverResponses.ContainsKey(item.TrackId)) + .Select(item => coverResponses[item.TrackId]) + .ToList(); + + responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers)); + } + + return responses; } - - return responses; } } diff --git a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs index e4928dc..e5a6520 100644 --- a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs +++ b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs @@ -4,37 +4,38 @@ using Musify.Application.Mixes.Responses; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Mixes; - - -internal static class MixItemMapper +namespace Musify.Application.Mixes { - public static async Task> ToResponsesAsync( - IDatabase database, - IReadOnlyList orderedItems, - CancellationToken cancellationToken) + + internal static class MixItemMapper { - var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList(); + public static async Task> ToResponsesAsync( + IDatabase database, + IReadOnlyList orderedItems, + CancellationToken cancellationToken) + { + var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList(); - var tracksById = await database.Tracks - .AsNoTracking() - .Include(track => track.Owner) - .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) - .Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) }) - .ToDictionaryAsync(x => x.Track.Id, cancellationToken); + var tracksById = await database.Tracks + .AsNoTracking() + .Include(track => track.Owner) + .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) }) + .ToDictionaryAsync(x => x.Track.Id, cancellationToken); - return orderedItems - .Where(item => tracksById.ContainsKey(item.TrackId)) - .Select(item => - { - var entry = tracksById[item.TrackId]; - return new MixItemApplicationResponse( - item.TrackId, - entry.Track.Title, - entry.Track.Owner.Name, - entry.Track.DurationSeconds, - entry.ListensCount); - }) - .ToList(); + return orderedItems + .Where(item => tracksById.ContainsKey(item.TrackId)) + .Select(item => + { + var entry = tracksById[item.TrackId]; + return new MixItemApplicationResponse( + item.TrackId, + entry.Track.Title, + entry.Track.Owner.Name, + entry.Track.DurationSeconds, + entry.ListensCount); + }) + .ToList(); + } } } diff --git a/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs index 801988e..6172bbc 100644 --- a/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs +++ b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs @@ -1,12 +1,13 @@ using Musify.Domain.ValueObjects; -namespace Musify.Application.Mixes.Responses; - -public record MixApplicationResponse( - Guid Id, - MixKind Kind, - int ItemCount, - IReadOnlyList Items) +namespace Musify.Application.Mixes.Responses { - public const int CoverItemCount = 4; + public record MixApplicationResponse( + Guid Id, + MixKind Kind, + int ItemCount, + IReadOnlyList Items) + { + public const int CoverItemCount = 4; + } } diff --git a/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs b/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs index 12d2192..1b75458 100644 --- a/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs +++ b/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs @@ -1,8 +1,9 @@ -namespace Musify.Application.Mixes.Responses; - -public record MixItemApplicationResponse( - Guid TrackId, - string Title, - string? Artist, - double DurationSeconds, - int ListensCount); +namespace Musify.Application.Mixes.Responses +{ + public record MixItemApplicationResponse( + Guid TrackId, + string Title, + string? Artist, + double DurationSeconds, + int ListensCount); +} diff --git a/backend/Core/Musify.Application/Pictures/GetCover.cs b/backend/Core/Musify.Application/Pictures/GetCover.cs index 6e91e2d..1e45cbc 100644 --- a/backend/Core/Musify.Application/Pictures/GetCover.cs +++ b/backend/Core/Musify.Application/Pictures/GetCover.cs @@ -7,69 +7,70 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Domain.ValueObjects; -namespace Musify.Application.Pictures; - -public enum CoverOwner +namespace Musify.Application.Pictures { - Track, - Album, - PlayList -} - -public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium) - : IQuery>; - -public record CoverLocation(string Bucket, string Key, string ContentType); - -public class GetCoverQueryHandler( - IDatabase database, - ApplicationStorageConfiguration storageConfiguration, - TrackConfiguration trackConfiguration, - AlbumConfiguration albumConfiguration, - PlayListConfiguration playListConfiguration) - : IQueryHandler> -{ - private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large); - - public async ValueTask> Handle(GetCoverQuery request, CancellationToken cancellationToken) + public enum CoverOwner { - var (source, routes) = request.Owner switch + Track, + Album, + PlayList + } + + public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium) + : IQuery>; + + public record CoverLocation(string Bucket, string Key, string ContentType); + + public class GetCoverQueryHandler( + IDatabase database, + ApplicationStorageConfiguration storageConfiguration, + TrackConfiguration trackConfiguration, + AlbumConfiguration albumConfiguration, + PlayListConfiguration playListConfiguration) + : IQueryHandler> + { + private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large); + + public async ValueTask> Handle(GetCoverQuery request, CancellationToken cancellationToken) { - CoverOwner.Track => (await database.Tracks.AsNoTracking() - .Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active) - .Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName)) - .SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes), - CoverOwner.Album => (await database.Albums.AsNoTracking() - .Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null) - .Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName)) - .SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes), - _ => (await database.PlayLists.AsNoTracking() - .Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null) - .Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName)) - .SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes) - }; + var (source, routes) = request.Owner switch + { + CoverOwner.Track => (await database.Tracks.AsNoTracking() + .Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active) + .Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName)) + .SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes), + CoverOwner.Album => (await database.Albums.AsNoTracking() + .Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null) + .Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName)) + .SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes), + _ => (await database.PlayLists.AsNoTracking() + .Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null) + .Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName)) + .SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes) + }; - var entity = request.Owner.ToString(); - if (source == null) - return AppErrors.NotFound($"{entity}Cover", request.Id); + var entity = request.Owner.ToString(); + if (source == null) + return AppErrors.NotFound($"{entity}Cover", request.Id); - var resizedName = request.Size switch - { - PictureSize.Small => source.Small, - PictureSize.Large => source.Large, - _ => source.Medium - }; + var resizedName = request.Size switch + { + PictureSize.Small => source.Small, + PictureSize.Large => source.Large, + _ => source.Medium + }; - // The resized files only exist once processing has finished; until then serve the original. - var (name, key) = !string.IsNullOrEmpty(resizedName) - ? (resizedName, routes.BuildPicturePath(request.Size, resizedName)) - : !string.IsNullOrEmpty(source.Original) - ? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original)) - : (null, null); + // The resized files only exist once processing has finished; until then serve the original. + var (name, key) = !string.IsNullOrEmpty(resizedName) + ? (resizedName, routes.BuildPicturePath(request.Size, resizedName)) + : !string.IsNullOrEmpty(source.Original) + ? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original)) + : (null, null); - if (name == null || key == null) - return AppErrors.NotFound($"{entity}Cover", request.Id); + if (name == null || key == null) + return AppErrors.NotFound($"{entity}Cover", request.Id); - return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name)); + return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name)); + } } } diff --git a/backend/Core/Musify.Application/Pictures/PictureSourceChange.cs b/backend/Core/Musify.Application/Pictures/PictureSourceChange.cs index ea8070b..90df675 100644 --- a/backend/Core/Musify.Application/Pictures/PictureSourceChange.cs +++ b/backend/Core/Musify.Application/Pictures/PictureSourceChange.cs @@ -5,31 +5,32 @@ using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Pictures; - -/// What an album or playlist needs to switch to a freshly uploaded picture. -public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes); - -public static class PictureSourceChange +namespace Musify.Application.Pictures { - public static async Task> PrepareAsync( - UploadIntentValidator uploadIntentValidator, - Guid intentId, - long userId, - UploadIntentPurpose purpose, - IPictureOwnerConfiguration owner, - CancellationToken cancellationToken) + /// What an album or playlist needs to switch to a freshly uploaded picture. + public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes); + + public static class PictureSourceChange { - var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken); - if (validation.IsError) - return validation.Errors; + public static async Task> PrepareAsync( + UploadIntentValidator uploadIntentValidator, + Guid intentId, + long userId, + UploadIntentPurpose purpose, + IPictureOwnerConfiguration owner, + CancellationToken cancellationToken) + { + var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken); + if (validation.IsError) + return validation.Errors; - var intent = validation.Value; + var intent = validation.Value; - return new PreparedPicture( - intent, - EntityPictures.Pending(intent.ObjectName), - owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName), - owner.PicturesSizes.ToImageSizes(owner.Routes)); + return new PreparedPicture( + intent, + EntityPictures.Pending(intent.ObjectName), + owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName), + owner.PicturesSizes.ToImageSizes(owner.Routes)); + } } } diff --git a/backend/Core/Musify.Application/Pictures/Responses/PictureUploadResponse.cs b/backend/Core/Musify.Application/Pictures/Responses/PictureUploadResponse.cs index 60cafb6..dc67b39 100644 --- a/backend/Core/Musify.Application/Pictures/Responses/PictureUploadResponse.cs +++ b/backend/Core/Musify.Application/Pictures/Responses/PictureUploadResponse.cs @@ -1,10 +1,11 @@ -namespace Musify.Application.Pictures.Responses; - -public record PictureUploadResponse( - Guid IntentId, - string Bucket, - string Key, - string PictureName, - string ContentType, - int ExpiresInSeconds, - string UploadUrl); +namespace Musify.Application.Pictures.Responses +{ + public record PictureUploadResponse( + Guid IntentId, + string Bucket, + string Key, + string PictureName, + string ContentType, + int ExpiresInSeconds, + string UploadUrl); +} diff --git a/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs index 12d5bb4..87db300 100644 --- a/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs @@ -7,67 +7,68 @@ using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists; - -/// Appends tracks to a playlist in the given order, skipping the ones it already has. -public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList TrackIds) - : ICommand>; - -public class AddTracksToPlayListCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.PlayLists { - public async ValueTask> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken) + /// Appends tracks to a playlist in the given order, skipping the ones it already has. + public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList TrackIds) + : ICommand>; + + public class AddTracksToPlayListCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); - if (playList.IsError) - return playList.Errors; - - var requestedIds = request.TrackIds.Distinct().ToList(); - - var existingIds = await database.Tracks - .AsNoTracking() - .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) - .Select(track => track.Id) - .ToListAsync(cancellationToken); - - var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id)); - if (missing != Guid.Empty) - return AppErrors.NotFound("Track", missing); - - var alreadyInPlayList = await database.PlayListHasTracks - .AsNoTracking() - .Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId)) - .Select(link => link.TrackId) - .ToListAsync(cancellationToken); - - var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList(); - if (toAdd.Count == 0) - return Result.Success; - - // Playlist positions are internal indexes, so they start at 0. - var nextPosition = (await database.PlayListHasTracks - .Where(link => link.PlayListId == request.PlayListId) - .MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1; - - foreach (var trackId in toAdd) + public async ValueTask> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken) { - await database.PlayListHasTracks.AddAsync(new PlayListHasTrack + var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (playList.IsError) + return playList.Errors; + + var requestedIds = request.TrackIds.Distinct().ToList(); + + var existingIds = await database.Tracks + .AsNoTracking() + .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => track.Id) + .ToListAsync(cancellationToken); + + var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id)); + if (missing != Guid.Empty) + return AppErrors.NotFound("Track", missing); + + var alreadyInPlayList = await database.PlayListHasTracks + .AsNoTracking() + .Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId)) + .Select(link => link.TrackId) + .ToListAsync(cancellationToken); + + var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList(); + if (toAdd.Count == 0) + return Result.Success; + + // Playlist positions are internal indexes, so they start at 0. + var nextPosition = (await database.PlayListHasTracks + .Where(link => link.PlayListId == request.PlayListId) + .MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1; + + foreach (var trackId in toAdd) { - PlayListId = request.PlayListId, - TrackId = trackId, - Position = nextPosition++ - }, cancellationToken); + await database.PlayListHasTracks.AddAsync(new PlayListHasTrack + { + PlayListId = request.PlayListId, + TrackId = trackId, + Position = nextPosition++ + }, cancellationToken); + } + + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken); + if (saved.IsError) + return saved; + + logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId); + + return Result.Success; } - - var saved = await database.TrySaveChangesAsync( - AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken); - if (saved.IsError) - return saved; - - logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId); - - return Result.Success; } } diff --git a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index 2b37a88..fec544d 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -12,67 +12,68 @@ using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists; - -public record CreatePlayListCommand( - long UserId, - string Name, - string? Description, - Guid? PictureIntentId, - PlayListVisibility Visibility = PlayListVisibility.Private) - : ICommand>; - -public class CreatePlayListCommandHandler( - IEventBus eventBus, - IDatabase database, - UploadIntentValidator uploadIntentValidator, - ILogger logger, - PlayListConfiguration playListConfiguration) - : ICommandHandler> +namespace Musify.Application.PlayLists { - public async ValueTask> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) + public record CreatePlayListCommand( + long UserId, + string Name, + string? Description, + Guid? PictureIntentId, + PlayListVisibility Visibility = PlayListVisibility.Private) + : ICommand>; + + public class CreatePlayListCommandHandler( + IEventBus eventBus, + IDatabase database, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + PlayListConfiguration playListConfiguration) + : ICommandHandler> { - var userExists = await database.Users - .AsNoTracking() - .AnyAsync(u => u.Id == request.UserId, cancellationToken); - if (!userExists) - return AppErrors.NotFound("User", request.UserId); - - PreparedPicture? picture = null; - if (request.PictureIntentId is { } intentId) + public async ValueTask> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) { - var prepared = await PictureSourceChange.PrepareAsync( - uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); - if (prepared.IsError) - return prepared.Errors; + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.UserId, cancellationToken); + if (!userExists) + return AppErrors.NotFound("User", request.UserId); - picture = prepared.Value; + PreparedPicture? picture = null; + if (request.PictureIntentId is { } intentId) + { + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; + + picture = prepared.Value; + } + + var name = request.Name.Trim(); + var playList = new PlayList + { + OwnerUserId = request.UserId, + Name = name, + NormalizedName = TextNormalizer.Normalize(name), + Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), + Pictures = picture?.Pictures, + Visibility = request.Visibility + }; + await database.PlayLists.AddAsync(playList, cancellationToken); + + if (picture != null) + { + await eventBus.PublishAsync( + new CreatePlayListResourcesEvent( + playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), + cancellationToken); + } + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId); + + return PlayListApplicationResponse.FromEntity(playList); } - - var name = request.Name.Trim(); - var playList = new PlayList - { - OwnerUserId = request.UserId, - Name = name, - NormalizedName = TextNormalizer.Normalize(name), - Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), - Pictures = picture?.Pictures, - Visibility = request.Visibility - }; - await database.PlayLists.AddAsync(playList, cancellationToken); - - if (picture != null) - { - await eventBus.PublishAsync( - new CreatePlayListResourcesEvent( - playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), - cancellationToken); - } - - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId); - - return PlayListApplicationResponse.FromEntity(playList); } } diff --git a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs index 6dad744..fa465ec 100644 --- a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs @@ -6,31 +6,32 @@ using Musify.Application.Events; using Musify.Application.Shared; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists; - -public record DeletePlayListCommand(long UserId, Guid PlayListId) - : ICommand>; - -public class DeletePlayListCommandHandler( - IDatabase database, - IEventBus eventBus, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.PlayLists { - public async ValueTask> Handle(DeletePlayListCommand request, CancellationToken cancellationToken) + public record DeletePlayListCommand(long UserId, Guid PlayListId) + : ICommand>; + + public class DeletePlayListCommandHandler( + IDatabase database, + IEventBus eventBus, + ILogger logger) + : ICommandHandler> { - var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); - if (found.IsError) - return found.Errors; + public async ValueTask> Handle(DeletePlayListCommand request, CancellationToken cancellationToken) + { + var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; - var playList = found.Value; - playList.LifeCycleStatus = LifeCycleStatus.Removing; + var playList = found.Value; + playList.LifeCycleStatus = LifeCycleStatus.Removing; - await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken); - await database.SaveChangesAsync(cancellationToken); + await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken); + await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId); + logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId); - return Result.Success; + return Result.Success; + } } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs index 8cbfdae..d532bc2 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs @@ -5,26 +5,27 @@ using Musify.Application.Contracts; using Musify.Application.PlayLists.Responses; using Musify.Application.Shared; -namespace Musify.Application.PlayLists; - -public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null) - : IQuery>; - -public class GetPlayListByIdQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.PlayLists { - public async ValueTask> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) - { - var playList = await database.PlayLists - .AsNoTracking() - .Active() - .VisibleTo(request.RequestingUserId) - .Where(p => p.Id == request.Id) - .SelectResponse() - .SingleOrDefaultAsync(cancellationToken); + public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null) + : IQuery>; - return playList == null - ? AppErrors.NotFound("PlayList", request.Id) - : playList; + public class GetPlayListByIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) + { + var playList = await database.PlayLists + .AsNoTracking() + .Active() + .VisibleTo(request.RequestingUserId) + .Where(p => p.Id == request.Id) + .SelectResponse() + .SingleOrDefaultAsync(cancellationToken); + + return playList == null + ? AppErrors.NotFound("PlayList", request.Id) + : playList; + } } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index 6aa90db..9da4317 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -7,35 +7,36 @@ using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists; - -public record GetPlayListTracksQuery( - Guid PlayListId, - int PageNumber, - int PageSize, - long? ViewerId = null) - : IQuery>>; - -public class GetPlayListTracksQueryHandler(IDatabase database) - : IQueryHandler>> +namespace Musify.Application.PlayLists { - public async ValueTask>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) - { - var playListVisible = await database.PlayLists - .AsNoTracking() - .Active() - .VisibleTo(request.ViewerId) - .AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken); - if (!playListVisible) - return AppErrors.NotFound("PlayList", request.PlayListId); + public record GetPlayListTracksQuery( + Guid PlayListId, + int PageNumber, + int PageSize, + long? ViewerId = null) + : IQuery>>; - return await database.PlayListHasTracks - .AsNoTracking() - .Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) - .OrderBy(playListTrack => playListTrack.Position) - .ThenBy(playListTrack => playListTrack.Id) - .Select(playListTrack => playListTrack.Track) - .SelectResponse() - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + public class GetPlayListTracksQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) + { + var playListVisible = await database.PlayLists + .AsNoTracking() + .Active() + .VisibleTo(request.ViewerId) + .AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken); + if (!playListVisible) + return AppErrors.NotFound("PlayList", request.PlayListId); + + return await database.PlayListHasTracks + .AsNoTracking() + .Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .OrderBy(playListTrack => playListTrack.Position) + .ThenBy(playListTrack => playListTrack.Id) + .Select(playListTrack => playListTrack.Track) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs index 6105348..14f49ee 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -4,37 +4,38 @@ using Musify.Application.Contracts; using Musify.Application.PlayLists.Responses; using Musify.Application.Shared; -namespace Musify.Application.PlayLists; - -public record GetPlayListsByUserIdQuery( - long UserId, - string? Name, - int PageNumber, - int PageSize, - long? ViewerId = null) - : IQuery>; - -public class GetPlayListsByUserIdQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.PlayLists { - public async ValueTask> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) + public record GetPlayListsByUserIdQuery( + long UserId, + string? Name, + int PageNumber, + int PageSize, + long? ViewerId = null) + : IQuery>; + + public class GetPlayListsByUserIdQueryHandler(IDatabase database) + : IQueryHandler> { - var playListsQuery = database.PlayLists - .AsNoTracking() - .Active() - .VisibleTo(request.ViewerId) - .Where(playList => playList.OwnerUserId == request.UserId); - - if (!string.IsNullOrWhiteSpace(request.Name)) + public async ValueTask> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) { - var normalizedName = TextNormalizer.Normalize(request.Name); - playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName)); - } + var playListsQuery = database.PlayLists + .AsNoTracking() + .Active() + .VisibleTo(request.ViewerId) + .Where(playList => playList.OwnerUserId == request.UserId); - return await playListsQuery - .OrderBy(playList => playList.CreatedAt) - .ThenBy(playList => playList.Id) - .SelectResponse() - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + if (!string.IsNullOrWhiteSpace(request.Name)) + { + var normalizedName = TextNormalizer.Normalize(request.Name); + playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName)); + } + + return await playListsQuery + .OrderBy(playList => playList.CreatedAt) + .ThenBy(playList => playList.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs index e6c44c2..1e071db 100644 --- a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs @@ -5,41 +5,42 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Shared; -namespace Musify.Application.PlayLists; - -public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) - : ICommand>; - -public class RemoveTrackFromPlayListCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.PlayLists { - public async ValueTask> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken) + public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) + : ICommand>; + + public class RemoveTrackFromPlayListCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); - if (playList.IsError) - return playList.Errors; + public async ValueTask> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken) + { + var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (playList.IsError) + return playList.Errors; - var playListTracks = await database.PlayListHasTracks - .Where(plt => plt.PlayListId == request.PlayListId) - .OrderBy(plt => plt.Position) - .ToListAsync(cancellationToken); + var playListTracks = await database.PlayListHasTracks + .Where(plt => plt.PlayListId == request.PlayListId) + .OrderBy(plt => plt.Position) + .ToListAsync(cancellationToken); - var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId); - if (link == null) - return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist."); + var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId); + if (link == null) + return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist."); - database.PlayListHasTracks.Remove(link); + database.PlayListHasTracks.Remove(link); - var position = 0; - foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id)) - remaining.Position = position++; + var position = 0; + foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id)) + remaining.Position = position++; - await database.SaveChangesAsync(cancellationToken); + await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId); + logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId); - return Result.Success; + return Result.Success; + } } } diff --git a/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs index 44d9e68..0ebfd95 100644 --- a/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs +++ b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs @@ -5,20 +5,21 @@ using Musify.Application.Pictures.Responses; using Musify.Application.Services; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists; - -public record RequestPlayListPictureUploadCommand( - long UserId, - string FileType, - string ContentType, - long? ExpectedSizeBytes = null) - : ICommand>; - -public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration) - : ICommandHandler> +namespace Musify.Application.PlayLists { - public async ValueTask> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) => - await issuer.IssuePictureAsync( - request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, - request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); + public record RequestPlayListPictureUploadCommand( + long UserId, + string FileType, + string ContentType, + long? ExpectedSizeBytes = null) + : ICommand>; + + public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration) + : ICommandHandler> + { + public async ValueTask> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) => + await issuer.IssuePictureAsync( + request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, + request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); + } } diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs index 48ea745..10e4021 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs @@ -2,44 +2,45 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists.Responses; - -public record PlayListApplicationResponse( - Guid Id, - string Name, - string? Description, - string? SmallImageKeyName, - string? MediumImageKeyName, - string? LargeImageKeyName, - PlayListVisibility Visibility, - DateTime CreatedAt, - DateTime UpdatedAt, - IReadOnlyList CoverTrackIds, - long OwnerUserId, - int TrackCount, - double DurationSeconds) +namespace Musify.Application.PlayLists.Responses { - public const int CoverTrackCount = PlayListProjections.CoverTrackCount; - - public static PlayListApplicationResponse FromEntity( - PlayList playList, - IReadOnlyList? coverTrackIds = null, - int trackCount = 0, - double durationSeconds = 0) + public record PlayListApplicationResponse( + Guid Id, + string Name, + string? Description, + string? SmallImageKeyName, + string? MediumImageKeyName, + string? LargeImageKeyName, + PlayListVisibility Visibility, + DateTime CreatedAt, + DateTime UpdatedAt, + IReadOnlyList CoverTrackIds, + long OwnerUserId, + int TrackCount, + double DurationSeconds) { - return new PlayListApplicationResponse( - playList.Id, - playList.Name, - playList.Description, - playList.Pictures?.SmallName, - playList.Pictures?.MediumName, - playList.Pictures?.LargeName, - playList.Visibility, - playList.CreatedAt, - playList.UpdatedAt, - coverTrackIds ?? [], - playList.OwnerUserId, - trackCount, - durationSeconds); + public const int CoverTrackCount = PlayListProjections.CoverTrackCount; + + public static PlayListApplicationResponse FromEntity( + PlayList playList, + IReadOnlyList? coverTrackIds = null, + int trackCount = 0, + double durationSeconds = 0) + { + return new PlayListApplicationResponse( + playList.Id, + playList.Name, + playList.Description, + playList.Pictures?.SmallName, + playList.Pictures?.MediumName, + playList.Pictures?.LargeName, + playList.Visibility, + playList.CreatedAt, + playList.UpdatedAt, + coverTrackIds ?? [], + playList.OwnerUserId, + trackCount, + durationSeconds); + } } } diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs index 9e23757..5f99617 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs @@ -1,37 +1,38 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists.Responses; - -public static class PlayListProjections +namespace Musify.Application.PlayLists.Responses { - public const int CoverTrackCount = 4; + public static class PlayListProjections + { + public const int CoverTrackCount = 4; - public static IQueryable Active(this IQueryable query) => - query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active); + public static IQueryable Active(this IQueryable query) => + query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active); - public static IQueryable VisibleTo(this IQueryable query, long? viewerId) => - query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId); + public static IQueryable VisibleTo(this IQueryable query, long? viewerId) => + query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId); - public static IQueryable SelectResponse(this IQueryable query) => - query.Select(playList => new PlayListApplicationResponse( - playList.Id, - playList.Name, - playList.Description, - playList.Pictures!.SmallName, - playList.Pictures!.MediumName, - playList.Pictures!.LargeName, - playList.Visibility, - playList.CreatedAt, - playList.UpdatedAt, - playList.PlayListTracks - .OrderBy(playListTrack => playListTrack.Position) - .Take(CoverTrackCount) - .Select(playListTrack => playListTrack.TrackId) - .ToList(), - playList.OwnerUserId, - playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active), - playList.PlayListTracks - .Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) - .Sum(playListTrack => playListTrack.Track.DurationSeconds))); + public static IQueryable SelectResponse(this IQueryable query) => + query.Select(playList => new PlayListApplicationResponse( + playList.Id, + playList.Name, + playList.Description, + playList.Pictures!.SmallName, + playList.Pictures!.MediumName, + playList.Pictures!.LargeName, + playList.Visibility, + playList.CreatedAt, + playList.UpdatedAt, + playList.PlayListTracks + .OrderBy(playListTrack => playListTrack.Position) + .Take(CoverTrackCount) + .Select(playListTrack => playListTrack.TrackId) + .ToList(), + playList.OwnerUserId, + playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active), + playList.PlayListTracks + .Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .Sum(playListTrack => playListTrack.Track.DurationSeconds))); + } } diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index 1d29ec1..6ae2c03 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -11,70 +11,71 @@ using Musify.Application.Services; using Musify.Application.Shared; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists; - -public record UpdatePlayListCommand( - long UserId, - Guid PlayListId, - string? NewName, - string? NewDescription, - Guid? NewPictureIntentId, - PlayListVisibility? NewVisibility = null) - : ICommand>; - -public class UpdatePlayListCommandHandler( - IEventBus eventBus, - IDatabase database, - UploadIntentValidator uploadIntentValidator, - ILogger logger, - PlayListConfiguration playListConfiguration) - : ICommandHandler> +namespace Musify.Application.PlayLists { - public async ValueTask> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) + public record UpdatePlayListCommand( + long UserId, + Guid PlayListId, + string? NewName, + string? NewDescription, + Guid? NewPictureIntentId, + PlayListVisibility? NewVisibility = null) + : ICommand>; + + public class UpdatePlayListCommandHandler( + IEventBus eventBus, + IDatabase database, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + PlayListConfiguration playListConfiguration) + : ICommandHandler> { - var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); - if (found.IsError) - return found.Errors; - - var playList = found.Value; - - if (!string.IsNullOrWhiteSpace(request.NewName)) + public async ValueTask> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) { - playList.Name = request.NewName.Trim(); - playList.NormalizedName = TextNormalizer.Normalize(request.NewName); + var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + + var playList = found.Value; + + if (!string.IsNullOrWhiteSpace(request.NewName)) + { + playList.Name = request.NewName.Trim(); + playList.NormalizedName = TextNormalizer.Normalize(request.NewName); + } + + // null means "unchanged"; a blank description clears it. + if (request.NewDescription != null) + playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim(); + + if (request.NewVisibility is { } visibility) + playList.Visibility = visibility; + + if (request.NewPictureIntentId is { } intentId) + { + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; + + var picture = prepared.Value; + playList.Pictures = picture.Pictures; + + await eventBus.PublishAsync( + new UpdatePlayListPictureSourceEvent( + playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), + cancellationToken); + } + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Updated playlist {PlayListId}", playList.Id); + + return await database.PlayLists + .AsNoTracking() + .Where(p => p.Id == playList.Id) + .SelectResponse() + .SingleAsync(cancellationToken); } - - // null means "unchanged"; a blank description clears it. - if (request.NewDescription != null) - playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim(); - - if (request.NewVisibility is { } visibility) - playList.Visibility = visibility; - - if (request.NewPictureIntentId is { } intentId) - { - var prepared = await PictureSourceChange.PrepareAsync( - uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); - if (prepared.IsError) - return prepared.Errors; - - var picture = prepared.Value; - playList.Pictures = picture.Pictures; - - await eventBus.PublishAsync( - new UpdatePlayListPictureSourceEvent( - playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), - cancellationToken); - } - - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("Updated playlist {PlayListId}", playList.Id); - - return await database.PlayLists - .AsNoTracking() - .Where(p => p.Id == playList.Id) - .SelectResponse() - .SingleAsync(cancellationToken); } } diff --git a/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs b/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs index 6786cf7..fb50164 100644 --- a/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs +++ b/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs @@ -2,34 +2,35 @@ using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; -namespace Musify.Application.Services; - -/// Builds the stream URL and signed ticket for a processed track. It never touches the database. -public sealed class TrackStreamIssuer( - IStreamTicketService ticketService, - TrackConfiguration trackConfiguration, - StreamGatewayConfiguration streamGatewayConfiguration, - PlaybackConfiguration playbackConfiguration) +namespace Musify.Application.Services { - public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId) + /// Builds the stream URL and signed ticket for a processed track. It never touches the database. + public sealed class TrackStreamIssuer( + IStreamTicketService ticketService, + TrackConfiguration trackConfiguration, + StreamGatewayConfiguration streamGatewayConfiguration, + PlaybackConfiguration playbackConfiguration) { - var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName); - var ticket = ticketService.IssueTicket(userId, $"{folderPath}/", AnonymousFragmentBytes(userId)); + public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId) + { + var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName); + var ticket = ticketService.IssueTicket(userId, $"{folderPath}/", AnonymousFragmentBytes(userId)); - var manifestUrl = string.Join('/', - streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'), - "media", - folderPath, - streamGatewayConfiguration.AudioFileName); + var manifestUrl = string.Join('/', + streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'), + "media", + folderPath, + streamGatewayConfiguration.AudioFileName); - return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId); - } + return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId); + } - private long? AnonymousFragmentBytes(long? userId) - { - if (userId != null || playbackConfiguration.AnonymousFragmentSeconds <= 0) - return null; + private long? AnonymousFragmentBytes(long? userId) + { + if (userId != null || playbackConfiguration.AnonymousFragmentSeconds <= 0) + return null; - return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond; + return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond; + } } } diff --git a/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs b/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs index 70adca1..1e36869 100644 --- a/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs @@ -8,126 +8,127 @@ using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Services; - -public sealed record UploadRequest( - UploadIntentPurpose Purpose, - PictureRoutes Routes, - string FileType, - string ContentType, - long? ExpectedSizeBytes); - -public sealed record IssuedUpload( - Guid IntentId, - string Key, - string ObjectName, - string ContentType, - string UploadUrl); - -/// Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first. -public sealed class UploadIntentIssuer( - IDatabase database, - IStorageService storageService, - UploadIntentValidator uploadIntentValidator, - ApplicationStorageConfiguration storageConfiguration, - UploadIntentConfiguration uploadIntentConfiguration, - ILogger logger) +namespace Musify.Application.Services { - public async Task>> IssueAsync( - long userId, - IReadOnlyList requests, - CancellationToken cancellationToken) + public sealed record UploadRequest( + UploadIntentPurpose Purpose, + PictureRoutes Routes, + string FileType, + string ContentType, + long? ExpectedSizeBytes); + + public sealed record IssuedUpload( + Guid IntentId, + string Key, + string ObjectName, + string ContentType, + string UploadUrl); + + /// Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first. + public sealed class UploadIntentIssuer( + IDatabase database, + IStorageService storageService, + UploadIntentValidator uploadIntentValidator, + ApplicationStorageConfiguration storageConfiguration, + UploadIntentConfiguration uploadIntentConfiguration, + ILogger logger) { - var userExists = await database.Users - .AsNoTracking() - .AnyAsync(user => user.Id == userId, cancellationToken); - if (!userExists) - return AppErrors.NotFound("User", userId); - - var planned = requests - .Select(request => - { - var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; - var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName); - var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose); - return (Request: request, ObjectName: objectName, Key: key, Size: size); - }) - .ToList(); - - await using var transaction = await database.BeginTransactionAsync( - System.Data.IsolationLevel.Serializable, cancellationToken); - - var quota = await uploadIntentValidator.CheckQuotaAsync( - userId, planned.Sum(item => item.Size), planned.Count, cancellationToken); - if (quota.IsError) - return quota.Errors; - - var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds); - var expiresAt = DateTime.UtcNow + expiresIn; - var issued = new List(planned.Count); - - foreach (var item in planned) + public async Task>> IssueAsync( + long userId, + IReadOnlyList requests, + CancellationToken cancellationToken) { - var uploadUrl = await storageService.GetUploadUrlAsync( - storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken: cancellationToken); + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(user => user.Id == userId, cancellationToken); + if (!userExists) + return AppErrors.NotFound("User", userId); - var intent = new UploadIntent + var planned = requests + .Select(request => + { + var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; + var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName); + var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose); + return (Request: request, ObjectName: objectName, Key: key, Size: size); + }) + .ToList(); + + await using var transaction = await database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, cancellationToken); + + var quota = await uploadIntentValidator.CheckQuotaAsync( + userId, planned.Sum(item => item.Size), planned.Count, cancellationToken); + if (quota.IsError) + return quota.Errors; + + var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds); + var expiresAt = DateTime.UtcNow + expiresIn; + var issued = new List(planned.Count); + + foreach (var item in planned) { - UserId = userId, - Bucket = storageConfiguration.Bucket, - Key = item.Key, - ObjectName = item.ObjectName, - ContentType = item.Request.ContentType, - ExpectedSizeBytes = item.Size, - Purpose = item.Request.Purpose, - ExpiresAt = expiresAt, - }; + var uploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken: cancellationToken); - await database.UploadIntents.AddAsync(intent, cancellationToken); - issued.Add(new IssuedUpload(intent.Id, item.Key, item.ObjectName, item.Request.ContentType, uploadUrl)); + var intent = new UploadIntent + { + UserId = userId, + Bucket = storageConfiguration.Bucket, + Key = item.Key, + ObjectName = item.ObjectName, + ContentType = item.Request.ContentType, + ExpectedSizeBytes = item.Size, + Purpose = item.Request.Purpose, + ExpiresAt = expiresAt, + }; + + await database.UploadIntents.AddAsync(intent, cancellationToken); + issued.Add(new IssuedUpload(intent.Id, item.Key, item.ObjectName, item.Request.ContentType, uploadUrl)); + } + + await database.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + logger.LogInformation("Issued {Count} upload intents for user {UserId} ({Purposes})", + issued.Count, userId, string.Join('/', planned.Select(item => item.Request.Purpose))); + + return issued; } - await database.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); + public async Task> IssueOneAsync(long userId, UploadRequest request, CancellationToken cancellationToken) + { + var issued = await IssueAsync(userId, [request], cancellationToken); + return issued.IsError ? issued.Errors : issued.Value[0]; + } - logger.LogInformation("Issued {Count} upload intents for user {UserId} ({Purposes})", - issued.Count, userId, string.Join('/', planned.Select(item => item.Request.Purpose))); + public async Task> IssuePictureAsync( + long userId, + UploadIntentPurpose purpose, + IPictureOwnerConfiguration owner, + string fileType, + string contentType, + long? expectedSizeBytes, + CancellationToken cancellationToken) + { + var issued = await IssueOneAsync( + userId, new UploadRequest(purpose, owner.Routes, fileType, contentType, expectedSizeBytes), cancellationToken); + if (issued.IsError) + return issued.Errors; - return issued; + return new PictureUploadResponse( + issued.Value.IntentId, + storageConfiguration.Bucket, + issued.Value.Key, + issued.Value.ObjectName, + issued.Value.ContentType, + uploadIntentConfiguration.UploadUrlExpiresInSeconds, + issued.Value.UploadUrl); + } + + private long DefaultSize(UploadIntentPurpose purpose) => + purpose == UploadIntentPurpose.TrackAudio + ? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes + : uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; } - - public async Task> IssueOneAsync(long userId, UploadRequest request, CancellationToken cancellationToken) - { - var issued = await IssueAsync(userId, [request], cancellationToken); - return issued.IsError ? issued.Errors : issued.Value[0]; - } - - public async Task> IssuePictureAsync( - long userId, - UploadIntentPurpose purpose, - IPictureOwnerConfiguration owner, - string fileType, - string contentType, - long? expectedSizeBytes, - CancellationToken cancellationToken) - { - var issued = await IssueOneAsync( - userId, new UploadRequest(purpose, owner.Routes, fileType, contentType, expectedSizeBytes), cancellationToken); - if (issued.IsError) - return issued.Errors; - - return new PictureUploadResponse( - issued.Value.IntentId, - storageConfiguration.Bucket, - issued.Value.Key, - issued.Value.ObjectName, - issued.Value.ContentType, - uploadIntentConfiguration.UploadUrlExpiresInSeconds, - issued.Value.UploadUrl); - } - - private long DefaultSize(UploadIntentPurpose purpose) => - purpose == UploadIntentPurpose.TrackAudio - ? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes - : uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; } diff --git a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs index 4888ea7..3855124 100644 --- a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs @@ -6,72 +6,73 @@ using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Services; - -public sealed class UploadIntentValidator( - IDatabase database, - IStorageService storageService, - UploadIntentConfiguration config) +namespace Musify.Application.Services { - public async Task> CheckQuotaAsync( - long userId, - long requiredBytes, - int requiredIntentCount, - CancellationToken cancellationToken) + public sealed class UploadIntentValidator( + IDatabase database, + IStorageService storageService, + UploadIntentConfiguration config) { - var now = DateTime.UtcNow; - var pictureDefault = config.DefaultExpectedPictureSizeBytes; - var audioDefault = config.DefaultExpectedAudioSizeBytes; + public async Task> CheckQuotaAsync( + long userId, + long requiredBytes, + int requiredIntentCount, + CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var pictureDefault = config.DefaultExpectedPictureSizeBytes; + var audioDefault = config.DefaultExpectedAudioSizeBytes; - var active = await database.UploadIntents - .AsNoTracking() - .Where(intent => intent.UserId == userId && intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt > now) - .GroupBy(_ => 1) - .Select(group => new - { - Count = group.Count(), - Bytes = group.Sum(intent => intent.ExpectedSizeBytes - ?? (intent.Purpose == UploadIntentPurpose.TrackAudio ? audioDefault : pictureDefault)) - }) - .SingleOrDefaultAsync(cancellationToken); + var active = await database.UploadIntents + .AsNoTracking() + .Where(intent => intent.UserId == userId && intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt > now) + .GroupBy(_ => 1) + .Select(group => new + { + Count = group.Count(), + Bytes = group.Sum(intent => intent.ExpectedSizeBytes + ?? (intent.Purpose == UploadIntentPurpose.TrackAudio ? audioDefault : pictureDefault)) + }) + .SingleOrDefaultAsync(cancellationToken); - if ((active?.Count ?? 0) + requiredIntentCount > config.MaxActiveUploadIntentsPerUser) - return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire."); + if ((active?.Count ?? 0) + requiredIntentCount > config.MaxActiveUploadIntentsPerUser) + return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire."); - if ((active?.Bytes ?? 0) + requiredBytes > config.MaxActiveUploadBytesPerUser) - return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire."); + if ((active?.Bytes ?? 0) + requiredBytes > config.MaxActiveUploadBytesPerUser) + return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire."); - return Result.Success; - } + return Result.Success; + } - public async Task> ValidateAndLoadAsync( - Guid intentId, - long userId, - UploadIntentPurpose purpose, - CancellationToken cancellationToken) - { - var intent = await database.UploadIntents - .FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken); + public async Task> ValidateAndLoadAsync( + Guid intentId, + long userId, + UploadIntentPurpose purpose, + CancellationToken cancellationToken) + { + var intent = await database.UploadIntents + .FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken); - if (intent == null || intent.UserId != userId) - return AppErrors.NotFound("UploadIntent", intentId); + if (intent == null || intent.UserId != userId) + return AppErrors.NotFound("UploadIntent", intentId); - if (intent.Purpose != purpose) - return Error.Validation(description: "Upload intent was issued for a different purpose."); + if (intent.Purpose != purpose) + return Error.Validation(description: "Upload intent was issued for a different purpose."); - if (intent.IsConsumed) - return AppErrors.Conflict("UploadIntent.Consumed", "Upload intent has already been consumed."); + if (intent.IsConsumed) + return AppErrors.Conflict("UploadIntent.Consumed", "Upload intent has already been consumed."); - if (intent.IsExpiredAt(DateTime.UtcNow)) - return Error.Validation(description: "Upload intent has expired."); + if (intent.IsExpiredAt(DateTime.UtcNow)) + return Error.Validation(description: "Upload intent has expired."); - var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken); - if (metadata == null) - return Error.NotFound("UploadIntent.ObjectMissing", "Uploaded object not found in storage. Upload the file first."); + var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken); + if (metadata == null) + return Error.NotFound("UploadIntent.ObjectMissing", "Uploaded object not found in storage. Upload the file first."); - if (metadata.ContentLength > config.MaxUploadBytes) - return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes."); + if (metadata.ContentLength > config.MaxUploadBytes) + return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes."); - return intent; + return intent; + } } } diff --git a/backend/Core/Musify.Application/Shared/AppErrors.cs b/backend/Core/Musify.Application/Shared/AppErrors.cs index 326d420..2bec4a7 100644 --- a/backend/Core/Musify.Application/Shared/AppErrors.cs +++ b/backend/Core/Musify.Application/Shared/AppErrors.cs @@ -1,15 +1,16 @@ using ErrorOr; -namespace Musify.Application.Shared; - -public static class AppErrors +namespace Musify.Application.Shared { - public static Error NotFound(string entity, object id) => - Error.NotFound($"{entity}.NotFound", $"{entity} {id} was not found."); + public static class AppErrors + { + public static Error NotFound(string entity, object id) => + Error.NotFound($"{entity}.NotFound", $"{entity} {id} was not found."); - public static Error Forbidden(string entity, object id) => - Error.Forbidden($"{entity}.Forbidden", $"You do not have permission to modify {entity} {id}."); + public static Error Forbidden(string entity, object id) => + Error.Forbidden($"{entity}.Forbidden", $"You do not have permission to modify {entity} {id}."); - public static Error Conflict(string code, string description) => - Error.Conflict(code, description); + public static Error Conflict(string code, string description) => + Error.Conflict(code, description); + } } diff --git a/backend/Core/Musify.Application/Shared/ImageSize.cs b/backend/Core/Musify.Application/Shared/ImageSize.cs index 0a9ebb4..b6a699e 100644 --- a/backend/Core/Musify.Application/Shared/ImageSize.cs +++ b/backend/Core/Musify.Application/Shared/ImageSize.cs @@ -1,11 +1,12 @@ -namespace Musify.Application.Shared; +namespace Musify.Application.Shared +{ + public record ImageSize( + string SavePath, + int Width, + int Height); -public record ImageSize( - string SavePath, - int Width, - int Height); - -public record ImageSizes( - ImageSize Small, - ImageSize Medium, - ImageSize Large); + public record ImageSizes( + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} diff --git a/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs b/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs index 5ad6072..af06273 100644 --- a/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs +++ b/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs @@ -3,26 +3,27 @@ using Microsoft.EntityFrameworkCore; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; -namespace Musify.Application.Shared; - -public static class OwnershipExtensions +namespace Musify.Application.Shared { - /// - /// Loads an active, tracked entity owned by : NotFound when it is missing - /// or not active, Forbidden when someone else owns it. - /// - public static async Task> FindOwnedAsync( - this DbSet set, Guid id, long userId, CancellationToken cancellationToken) - where T : class, IOwnedEntity, IHasLifeCycle + public static class OwnershipExtensions { - var entity = await set.SingleOrDefaultAsync( - e => e.Id == id && e.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); + /// + /// Loads an active, tracked entity owned by : NotFound when it is missing + /// or not active, Forbidden when someone else owns it. + /// + public static async Task> FindOwnedAsync( + this DbSet set, Guid id, long userId, CancellationToken cancellationToken) + where T : class, IOwnedEntity, IHasLifeCycle + { + var entity = await set.SingleOrDefaultAsync( + e => e.Id == id && e.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); - if (entity == null) - return AppErrors.NotFound(typeof(T).Name, id); + if (entity == null) + return AppErrors.NotFound(typeof(T).Name, id); - return entity.OwnerUserId == userId - ? entity - : AppErrors.Forbidden(typeof(T).Name, id); + return entity.OwnerUserId == userId + ? entity + : AppErrors.Forbidden(typeof(T).Name, id); + } } } diff --git a/backend/Core/Musify.Application/Shared/Pagination.cs b/backend/Core/Musify.Application/Shared/Pagination.cs index 451669e..a58addf 100644 --- a/backend/Core/Musify.Application/Shared/Pagination.cs +++ b/backend/Core/Musify.Application/Shared/Pagination.cs @@ -1,45 +1,46 @@ using Microsoft.EntityFrameworkCore; -namespace Musify.Application.Shared; - -public record PageRequest(int PageNumber = 1, int PageSize = 20) +namespace Musify.Application.Shared { - public const int MaxPageSize = 100; -} - -public record PaginatedResponse( - IReadOnlyList Items, - int PageNumber, - int PageSize, - int PageCount, - int TotalItemCount, - bool HasNextPage, - bool HasPreviousPage); - -public static class PaginationExtensions -{ - /// Counts and pages the same (already filtered, ordered and projected) query. - public static async Task> ToPaginatedAsync( - this IQueryable query, PageRequest page, CancellationToken cancellationToken) + public record PageRequest(int PageNumber = 1, int PageSize = 20) { - var pageNumber = Math.Max(page.PageNumber, 1); - var pageSize = Math.Clamp(page.PageSize, 1, PageRequest.MaxPageSize); + public const int MaxPageSize = 100; + } - var totalCount = await query.CountAsync(cancellationToken); - var items = await query - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize) - .ToListAsync(cancellationToken); + public record PaginatedResponse( + IReadOnlyList Items, + int PageNumber, + int PageSize, + int PageCount, + int TotalItemCount, + bool HasNextPage, + bool HasPreviousPage); - var pageCount = (int)Math.Ceiling(totalCount / (double)pageSize); + public static class PaginationExtensions + { + /// Counts and pages the same (already filtered, ordered and projected) query. + public static async Task> ToPaginatedAsync( + this IQueryable query, PageRequest page, CancellationToken cancellationToken) + { + var pageNumber = Math.Max(page.PageNumber, 1); + var pageSize = Math.Clamp(page.PageSize, 1, PageRequest.MaxPageSize); - return new PaginatedResponse( - items, - pageNumber, - pageSize, - pageCount, - totalCount, - HasNextPage: pageNumber < pageCount, - HasPreviousPage: pageNumber > 1 && pageCount > 0); + var totalCount = await query.CountAsync(cancellationToken); + var items = await query + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + var pageCount = (int)Math.Ceiling(totalCount / (double)pageSize); + + return new PaginatedResponse( + items, + pageNumber, + pageSize, + pageCount, + totalCount, + HasNextPage: pageNumber < pageCount, + HasPreviousPage: pageNumber > 1 && pageCount > 0); + } } } diff --git a/backend/Core/Musify.Application/Shared/StorageKey.cs b/backend/Core/Musify.Application/Shared/StorageKey.cs index 4837207..82ddca8 100644 --- a/backend/Core/Musify.Application/Shared/StorageKey.cs +++ b/backend/Core/Musify.Application/Shared/StorageKey.cs @@ -1,11 +1,12 @@ -namespace Musify.Application.Shared; - -public static class StorageKey +namespace Musify.Application.Shared { - public static string Combine(params string[] segments) + public static class StorageKey { - return string.Join('/', segments - .Where(static s => !string.IsNullOrWhiteSpace(s)) - .Select(static s => s.Trim().Trim('/', '\\'))); + public static string Combine(params string[] segments) + { + return string.Join('/', segments + .Where(static s => !string.IsNullOrWhiteSpace(s)) + .Select(static s => s.Trim().Trim('/', '\\'))); + } } } diff --git a/backend/Core/Musify.Application/Shared/TextNormalizer.cs b/backend/Core/Musify.Application/Shared/TextNormalizer.cs index e0c3e46..3611c12 100644 --- a/backend/Core/Musify.Application/Shared/TextNormalizer.cs +++ b/backend/Core/Musify.Application/Shared/TextNormalizer.cs @@ -1,6 +1,7 @@ -namespace Musify.Application.Shared; - -public static class TextNormalizer +namespace Musify.Application.Shared { - public static string Normalize(string value) => value.Trim().ToUpperInvariant(); + public static class TextNormalizer + { + public static string Normalize(string value) => value.Trim().ToUpperInvariant(); + } } diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index 52ec80d..ad734de 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -11,103 +11,104 @@ using Musify.Application.Tracks.Responses; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks; - -public record CreateTrackCommand( - long UserId, - string Title, - Guid PictureIntentId, - Guid AudioIntentId, - IReadOnlyCollection Tags, - bool IsExplicit = false) - : ICommand>; - -public class CreateTrackCommandHandler( - IDatabase database, - IEventBus eventBus, - UploadIntentValidator uploadIntentValidator, - ILogger logger, - TrackConfiguration trackConfiguration) - : ICommandHandler> +namespace Musify.Application.Tracks { - public async ValueTask> Handle(CreateTrackCommand request, CancellationToken cancellationToken) + public record CreateTrackCommand( + long UserId, + string Title, + Guid PictureIntentId, + Guid AudioIntentId, + IReadOnlyCollection Tags, + bool IsExplicit = false) + : ICommand>; + + public class CreateTrackCommandHandler( + IDatabase database, + IEventBus eventBus, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + TrackConfiguration trackConfiguration) + : ICommandHandler> { - var user = await database.Users - .SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); - if (user == null) + public async ValueTask> Handle(CreateTrackCommand request, CancellationToken cancellationToken) { - logger.LogWarning("User {UserId} not found", request.UserId); - return Error.NotFound(description: $"User {request.UserId} not found"); - } - - var distinctTags = request.Tags.Distinct().ToList(); - - if (request.PictureIntentId == request.AudioIntentId) - return Error.Validation(description: "Picture and audio must use different upload intents."); - - var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( - request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken); - if (pictureValidation.IsError) - return pictureValidation.Errors; - - var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync( - request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken); - if (audioValidation.IsError) - return audioValidation.Errors; - - var pictureIntent = pictureValidation.Value; - var audioIntent = audioValidation.Value; - - var finalPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); - var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName); - var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString()); - - var trackEntity = new Track - { - Title = request.Title.Trim(), - NormalizedTitle = TextNormalizer.Normalize(request.Title), - OwnerUserId = user.Id, - Owner = user, - IsExplicit = request.IsExplicit, - Pictures = new TrackPictures + var user = await database.Users + .SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + if (user == null) { - OriginalName = pictureIntent.ObjectName, - SmallName = trackConfiguration.Routes.PresetSmallPicture, - MediumName = trackConfiguration.Routes.PresetMediumPicture, - LargeName = trackConfiguration.Routes.PresetLargePicture, - ProcessingStatus = ProcessingStatus.Pending - }, - Audio = new TrackAudio - { - OriginalName = audioIntent.ObjectName, - TranscodeStatus = ProcessingStatus.Pending + logger.LogWarning("User {UserId} not found", request.UserId); + return Error.NotFound(description: $"User {request.UserId} not found"); } - }; - trackEntity.Tags = distinctTags - .Select(tag => new TrackTag { TrackId = trackEntity.Id, Tag = tag }) - .ToList(); + var distinctTags = request.Tags.Distinct().ToList(); - await database.Tracks.AddAsync(trackEntity, cancellationToken); + if (request.PictureIntentId == request.AudioIntentId) + return Error.Validation(description: "Picture and audio must use different upload intents."); + + var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( + request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken); + if (pictureValidation.IsError) + return pictureValidation.Errors; + + var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync( + request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken); + if (audioValidation.IsError) + return audioValidation.Errors; + + var pictureIntent = pictureValidation.Value; + var audioIntent = audioValidation.Value; + + var finalPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); + var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName); + var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString()); + + var trackEntity = new Track + { + Title = request.Title.Trim(), + NormalizedTitle = TextNormalizer.Normalize(request.Title), + OwnerUserId = user.Id, + Owner = user, + IsExplicit = request.IsExplicit, + Pictures = new TrackPictures + { + OriginalName = pictureIntent.ObjectName, + SmallName = trackConfiguration.Routes.PresetSmallPicture, + MediumName = trackConfiguration.Routes.PresetMediumPicture, + LargeName = trackConfiguration.Routes.PresetLargePicture, + ProcessingStatus = ProcessingStatus.Pending + }, + Audio = new TrackAudio + { + OriginalName = audioIntent.ObjectName, + TranscodeStatus = ProcessingStatus.Pending + } + }; + + trackEntity.Tags = distinctTags + .Select(tag => new TrackTag { TrackId = trackEntity.Id, Tag = tag }) + .ToList(); + + await database.Tracks.AddAsync(trackEntity, cancellationToken); - var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes); - await eventBus.PublishAsync( - new CreateTrackResourcesEvent( - trackEntity.Id, - pictureIntent.Id, - audioIntent.Id, - pictureIntent.Bucket, - pictureIntent.Key, - finalPictureKey, - audioIntent.Key, - finalAudioKey, - audioProcessedFolderKey, - sizes), - cancellationToken); + var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes); + await eventBus.PublishAsync( + new CreateTrackResourcesEvent( + trackEntity.Id, + pictureIntent.Id, + audioIntent.Id, + pictureIntent.Bucket, + pictureIntent.Key, + finalPictureKey, + audioIntent.Key, + finalAudioKey, + audioProcessedFolderKey, + sizes), + cancellationToken); - await database.SaveChangesAsync(cancellationToken); + await database.SaveChangesAsync(cancellationToken); - return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0); + return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0); + } } } diff --git a/backend/Core/Musify.Application/Tracks/DeleteStaleUncountedListens.cs b/backend/Core/Musify.Application/Tracks/DeleteStaleUncountedListens.cs index 7cdfaef..bdd7c39 100644 --- a/backend/Core/Musify.Application/Tracks/DeleteStaleUncountedListens.cs +++ b/backend/Core/Musify.Application/Tracks/DeleteStaleUncountedListens.cs @@ -3,30 +3,31 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -namespace Musify.Application.Tracks; - -public record DeleteStaleUncountedListensCommand : ICommand; - -public class DeleteStaleUncountedListensCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler +namespace Musify.Application.Tracks { - public async ValueTask Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken) + public record DeleteStaleUncountedListensCommand : ICommand; + + public class DeleteStaleUncountedListensCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler { - var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays); + public async ValueTask Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken) + { + var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays); - var stale = await database.ListeningHistories - .Where(l => !l.IsCounted && l.ListenedAt < cutoff) - .ToListAsync(cancellationToken); + var stale = await database.ListeningHistories + .Where(l => !l.IsCounted && l.ListenedAt < cutoff) + .ToListAsync(cancellationToken); - if (stale.Count == 0) - return 0; + if (stale.Count == 0) + return 0; - database.ListeningHistories.RemoveRange(stale); - await database.SaveChangesAsync(cancellationToken); + database.ListeningHistories.RemoveRange(stale); + await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays); - return stale.Count; + logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays); + return stale.Count; + } } } diff --git a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs index bde1aa1..b7c772a 100644 --- a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs +++ b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs @@ -6,36 +6,37 @@ using Musify.Application.Events; using Musify.Application.Shared; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks; - -public record DeleteTrackCommand( - long UserId, - Guid TrackId) - : ICommand>; - -public class DeleteTrackCommandHandler( - IDatabase database, - IEventBus eventBus, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.Tracks { - public async ValueTask> Handle(DeleteTrackCommand request, CancellationToken cancellationToken) + public record DeleteTrackCommand( + long UserId, + Guid TrackId) + : ICommand>; + + public class DeleteTrackCommandHandler( + IDatabase database, + IEventBus eventBus, + ILogger logger) + : ICommandHandler> { - var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken); - if (found.IsError) - return found.Errors; + public async ValueTask> Handle(DeleteTrackCommand request, CancellationToken cancellationToken) + { + var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; - var track = found.Value; - if (track.Audio.IsInProgress) - return AppErrors.Conflict("Track.Processing", "Track is currently being processed and cannot be deleted."); + var track = found.Value; + if (track.Audio.IsInProgress) + return AppErrors.Conflict("Track.Processing", "Track is currently being processed and cannot be deleted."); - track.LifeCycleStatus = LifeCycleStatus.Removing; + track.LifeCycleStatus = LifeCycleStatus.Removing; - await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken); - await database.SaveChangesAsync(cancellationToken); + await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken); + await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Marked track {TrackId} as removing", request.TrackId); + logger.LogInformation("Marked track {TrackId} as removing", request.TrackId); - return Result.Success; + return Result.Success; + } } } diff --git a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs index 4048ba3..55e34af 100644 --- a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs @@ -4,23 +4,24 @@ using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks; - -public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery; - -public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database) - : IQueryHandler +namespace Musify.Application.Tracks { - public async ValueTask Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken) + public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery; + + public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database) + : IQueryHandler { - return await database.ListeningHistories - .AsNoTracking() - .Include(t => t.Track.Owner) - .Include(t => t.Track.Tags) - .Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active) - .OrderByDescending(t => t.ListenedAt) - .ThenBy(t => t.Id) - .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted))) - .FirstOrDefaultAsync(cancellationToken); + public async ValueTask Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken) + { + return await database.ListeningHistories + .AsNoTracking() + .Include(t => t.Track.Owner) + .Include(t => t.Track.Tags) + .Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active) + .OrderByDescending(t => t.ListenedAt) + .ThenBy(t => t.Id) + .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted))) + .FirstOrDefaultAsync(cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs index 2a44802..48a797a 100644 --- a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs +++ b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs @@ -3,37 +3,38 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; -namespace Musify.Application.Tracks; - -public record GetListeningHistoryQuery(long UserId) : IQuery>; - -public class GetListeningHistoryQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Tracks { - public const int ListSize = 15; + public record GetListeningHistoryQuery(long UserId) : IQuery>; - public async ValueTask> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken) + public class GetListeningHistoryQueryHandler(IDatabase database) + : IQueryHandler> { - var recentTrackIds = await database.ListeningHistories - .AsNoTracking() - .Where(l => l.UserId == query.UserId && l.IsCounted) - .GroupBy(l => l.TrackId) - .OrderByDescending(g => g.Max(l => l.ListenedAt)) - .ThenBy(g => g.Key) - .Select(g => g.Key) - .Take(ListSize) - .ToListAsync(cancellationToken); + public const int ListSize = 15; - var tracksById = await database.Tracks - .AsNoTracking() - .Active() - .Where(t => recentTrackIds.Contains(t.Id)) - .SelectResponse() - .ToDictionaryAsync(t => t.Id, cancellationToken); + public async ValueTask> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken) + { + var recentTrackIds = await database.ListeningHistories + .AsNoTracking() + .Where(l => l.UserId == query.UserId && l.IsCounted) + .GroupBy(l => l.TrackId) + .OrderByDescending(g => g.Max(l => l.ListenedAt)) + .ThenBy(g => g.Key) + .Select(g => g.Key) + .Take(ListSize) + .ToListAsync(cancellationToken); - return recentTrackIds - .Where(id => tracksById.ContainsKey(id)) - .Select(id => tracksById[id]) - .ToList(); + var tracksById = await database.Tracks + .AsNoTracking() + .Active() + .Where(t => recentTrackIds.Contains(t.Id)) + .SelectResponse() + .ToDictionaryAsync(t => t.Id, cancellationToken); + + return recentTrackIds + .Where(id => tracksById.ContainsKey(id)) + .Select(id => tracksById[id]) + .ToList(); + } } } diff --git a/backend/Core/Musify.Application/Tracks/GetTrackById.cs b/backend/Core/Musify.Application/Tracks/GetTrackById.cs index 3614661..c6a5d0a 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackById.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackById.cs @@ -5,25 +5,26 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -namespace Musify.Application.Tracks; - -public record GetTrackByIdQuery(Guid TrackId) - : IQuery>; - -public class GetTrackByIdQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Tracks { - public async ValueTask> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) - { - var track = await database.Tracks - .AsNoTracking() - .Active() - .Where(t => t.Id == request.TrackId) - .SelectResponse() - .SingleOrDefaultAsync(cancellationToken); + public record GetTrackByIdQuery(Guid TrackId) + : IQuery>; - return track == null - ? AppErrors.NotFound("Track", request.TrackId) - : track; + public class GetTrackByIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) + { + var track = await database.Tracks + .AsNoTracking() + .Active() + .Where(t => t.Id == request.TrackId) + .SelectResponse() + .SingleOrDefaultAsync(cancellationToken); + + return track == null + ? AppErrors.NotFound("Track", request.TrackId) + : track; + } } } diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index 6120e24..e8ed4e9 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -5,33 +5,34 @@ using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks; - -public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null) - : IQuery>; - -public class GetTracksQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Tracks { - public async ValueTask> Handle(GetTracksQuery request, CancellationToken cancellationToken) + public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null) + : IQuery>; + + public class GetTracksQueryHandler(IDatabase database) + : IQueryHandler> { - var tracksQuery = database.Tracks - .AsNoTracking() - .Active(); - - if (!string.IsNullOrWhiteSpace(request.Name)) + public async ValueTask> Handle(GetTracksQuery request, CancellationToken cancellationToken) { - var normalizedName = TextNormalizer.Normalize(request.Name); - tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName)); + var tracksQuery = database.Tracks + .AsNoTracking() + .Active(); + + if (!string.IsNullOrWhiteSpace(request.Name)) + { + var normalizedName = TextNormalizer.Normalize(request.Name); + tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName)); + } + + if (request.Genre is { } genre) + tracksQuery = tracksQuery.Where(track => track.Tags.Any(tag => tag.Tag == genre)); + + return await tracksQuery + .OrderByDescending(track => track.CreatedAt) + .ThenBy(track => track.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } - - if (request.Genre is { } genre) - tracksQuery = tracksQuery.Where(track => track.Tags.Any(tag => tag.Tag == genre)); - - return await tracksQuery - .OrderByDescending(track => track.CreatedAt) - .ThenBy(track => track.Id) - .SelectResponse() - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index 5ec82be..43b799d 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -5,35 +5,36 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -namespace Musify.Application.Tracks; - -public record GetTracksByUserIdQuery( - long UserId, - string? Name, - int PageNumber, - int PageSize) - : IQuery>>; - -public class GetTracksByUserIdQueryHandler(IDatabase database) - : IQueryHandler>> +namespace Musify.Application.Tracks { - public async ValueTask>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) + public record GetTracksByUserIdQuery( + long UserId, + string? Name, + int PageNumber, + int PageSize) + : IQuery>>; + + public class GetTracksByUserIdQueryHandler(IDatabase database) + : IQueryHandler>> { - var tracksQuery = database.Tracks - .AsNoTracking() - .Active() - .Where(track => track.OwnerUserId == request.UserId); - - if (!string.IsNullOrWhiteSpace(request.Name)) + public async ValueTask>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) { - var normalizedName = TextNormalizer.Normalize(request.Name); - tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName)); - } + var tracksQuery = database.Tracks + .AsNoTracking() + .Active() + .Where(track => track.OwnerUserId == request.UserId); - return await tracksQuery - .OrderByDescending(track => track.CreatedAt) - .ThenBy(track => track.Id) - .SelectResponse() - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + if (!string.IsNullOrWhiteSpace(request.Name)) + { + var normalizedName = TextNormalizer.Normalize(request.Name); + tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName)); + } + + return await tracksQuery + .OrderByDescending(track => track.CreatedAt) + .ThenBy(track => track.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/Tracks/ListeningRules.cs b/backend/Core/Musify.Application/Tracks/ListeningRules.cs index 2992575..146ef1e 100644 --- a/backend/Core/Musify.Application/Tracks/ListeningRules.cs +++ b/backend/Core/Musify.Application/Tracks/ListeningRules.cs @@ -1,13 +1,14 @@ -namespace Musify.Application.Tracks; - -public static class ListeningRules +namespace Musify.Application.Tracks { - public const double CountedThresholdSeconds = 30; - public const double CountedThresholdFraction = 0.5; - public const double DurationToleranceSeconds = 2; - public const double ClockToleranceSeconds = 5; - public const int UncountedRetentionDays = 8; + public static class ListeningRules + { + public const double CountedThresholdSeconds = 30; + public const double CountedThresholdFraction = 0.5; + public const double DurationToleranceSeconds = 2; + public const double ClockToleranceSeconds = 5; + public const int UncountedRetentionDays = 8; - public static double CountedThreshold(double durationSeconds) => - Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction); + public static double CountedThreshold(double durationSeconds) => + Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction); + } } diff --git a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs index 9ced6ff..b00696e 100644 --- a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs +++ b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs @@ -4,38 +4,39 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; -namespace Musify.Application.Tracks; - -public record RecordListeningProgressCommand( - long UserId, - Guid ListenId, - double PlayedSeconds) - : ICommand>; - -public class RecordListeningProgressCommandHandler(IDatabase database) - : ICommandHandler> +namespace Musify.Application.Tracks { - public async ValueTask> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken) + public record RecordListeningProgressCommand( + long UserId, + Guid ListenId, + double PlayedSeconds) + : ICommand>; + + public class RecordListeningProgressCommandHandler(IDatabase database) + : ICommandHandler> { - var listen = await database.ListeningHistories - .Include(l => l.Track) - .SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken); + public async ValueTask> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken) + { + var listen = await database.ListeningHistories + .Include(l => l.Track) + .SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken); - if (listen == null) - return AppErrors.NotFound("Listen", request.ListenId); + if (listen == null) + return AppErrors.NotFound("Listen", request.ListenId); - var now = DateTime.UtcNow; - var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds; - var clockCap = (now - listen.ListenedAt).TotalSeconds + ListeningRules.ClockToleranceSeconds; - var accepted = Math.Min(request.PlayedSeconds, Math.Min(durationCap, clockCap)); + var now = DateTime.UtcNow; + var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds; + var clockCap = (now - listen.ListenedAt).TotalSeconds + ListeningRules.ClockToleranceSeconds; + var accepted = Math.Min(request.PlayedSeconds, Math.Min(durationCap, clockCap)); - listen.PlayedSeconds = Math.Max(listen.PlayedSeconds ?? 0, accepted); - listen.LastProgressAt = now; - listen.IsCounted = listen.IsCounted - || listen.PlayedSeconds >= ListeningRules.CountedThreshold(listen.Track.DurationSeconds); + listen.PlayedSeconds = Math.Max(listen.PlayedSeconds ?? 0, accepted); + listen.LastProgressAt = now; + listen.IsCounted = listen.IsCounted + || listen.PlayedSeconds >= ListeningRules.CountedThreshold(listen.Track.DurationSeconds); - await database.SaveChangesAsync(cancellationToken); + await database.SaveChangesAsync(cancellationToken); - return Result.Success; + return Result.Success; + } } } diff --git a/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs index 5c51124..98fcad9 100644 --- a/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs +++ b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs @@ -5,53 +5,54 @@ using Musify.Application.Services; using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks; - -public record RequestTrackUploadUrlsCommand( - long UserId, - string PictureFileType, - string PictureContentType, - string AudioFileType, - string AudioContentType, - long? ExpectedPictureSizeBytes = null, - long? ExpectedAudioSizeBytes = null) - : ICommand>; - -public class RequestTrackUploadUrlsCommandHandler( - UploadIntentIssuer issuer, - ApplicationStorageConfiguration storageConfiguration, - TrackConfiguration trackConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : ICommandHandler> +namespace Musify.Application.Tracks { - public async ValueTask> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken) + public record RequestTrackUploadUrlsCommand( + long UserId, + string PictureFileType, + string PictureContentType, + string AudioFileType, + string AudioContentType, + long? ExpectedPictureSizeBytes = null, + long? ExpectedAudioSizeBytes = null) + : ICommand>; + + public class RequestTrackUploadUrlsCommandHandler( + UploadIntentIssuer issuer, + ApplicationStorageConfiguration storageConfiguration, + TrackConfiguration trackConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> { - var routes = trackConfiguration.Routes; - var issued = await issuer.IssueAsync( - request.UserId, - [ - new UploadRequest(UploadIntentPurpose.TrackPicture, routes, request.PictureFileType, request.PictureContentType, request.ExpectedPictureSizeBytes), - new UploadRequest(UploadIntentPurpose.TrackAudio, routes, request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes) - ], - cancellationToken); - if (issued.IsError) - return issued.Errors; + public async ValueTask> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken) + { + var routes = trackConfiguration.Routes; + var issued = await issuer.IssueAsync( + request.UserId, + [ + new UploadRequest(UploadIntentPurpose.TrackPicture, routes, request.PictureFileType, request.PictureContentType, request.ExpectedPictureSizeBytes), + new UploadRequest(UploadIntentPurpose.TrackAudio, routes, request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes) + ], + cancellationToken); + if (issued.IsError) + return issued.Errors; - var picture = issued.Value[0]; - var audio = issued.Value[1]; + var picture = issued.Value[0]; + var audio = issued.Value[1]; - return new TrackUploadUrlsResponse( - picture.IntentId, - audio.IntentId, - storageConfiguration.Bucket, - picture.Key, - picture.ObjectName, - picture.ContentType, - picture.UploadUrl, - audio.Key, - audio.ObjectName, - audio.ContentType, - audio.UploadUrl, - uploadIntentConfiguration.UploadUrlExpiresInSeconds); + return new TrackUploadUrlsResponse( + picture.IntentId, + audio.IntentId, + storageConfiguration.Bucket, + picture.Key, + picture.ObjectName, + picture.ContentType, + picture.UploadUrl, + audio.Key, + audio.ObjectName, + audio.ContentType, + audio.UploadUrl, + uploadIntentConfiguration.UploadUrlExpiresInSeconds); + } } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs index da88141..95dd13a 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs @@ -1,32 +1,33 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks.Responses; - -public record TrackApplicationResponse( - Guid Id, - string Title, - string? Artist, - ProcessingStatus AudioStatus, - double Duration, - int ListensCount, - DateTime CreatedAt, - DateTime UpdatedAt, - long OwnerUserId, - IReadOnlyCollection Tags, - bool IsExplicit) +namespace Musify.Application.Tracks.Responses { - public static TrackApplicationResponse FromEntity(Track track, int listensCount) => - new( - track.Id, - track.Title, - track.Owner.Name, - track.Audio.TranscodeStatus, - track.DurationSeconds, - listensCount, - track.CreatedAt, - track.UpdatedAt, - track.OwnerUserId, - track.Tags.Select(tag => tag.Tag).ToList(), - track.IsExplicit); + public record TrackApplicationResponse( + Guid Id, + string Title, + string? Artist, + ProcessingStatus AudioStatus, + double Duration, + int ListensCount, + DateTime CreatedAt, + DateTime UpdatedAt, + long OwnerUserId, + IReadOnlyCollection Tags, + bool IsExplicit) + { + public static TrackApplicationResponse FromEntity(Track track, int listensCount) => + new( + track.Id, + track.Title, + track.Owner.Name, + track.Audio.TranscodeStatus, + track.DurationSeconds, + listensCount, + track.CreatedAt, + track.UpdatedAt, + track.OwnerUserId, + track.Tags.Select(tag => tag.Tag).ToList(), + track.IsExplicit); + } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs index 33fa4f8..df228d3 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs @@ -1,24 +1,25 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks.Responses; - -public static class TrackProjections +namespace Musify.Application.Tracks.Responses { - public static IQueryable Active(this IQueryable query) => - query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active); + public static class TrackProjections + { + public static IQueryable Active(this IQueryable query) => + query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active); - public static IQueryable SelectResponse(this IQueryable query) => - query.Select(track => new TrackApplicationResponse( - track.Id, - track.Title, - track.Owner.Name, - track.Audio.TranscodeStatus, - track.DurationSeconds, - track.ListeningHistories.Count(listen => listen.IsCounted), - track.CreatedAt, - track.UpdatedAt, - track.OwnerUserId, - track.Tags.Select(tag => tag.Tag).ToList(), - track.IsExplicit)); + public static IQueryable SelectResponse(this IQueryable query) => + query.Select(track => new TrackApplicationResponse( + track.Id, + track.Title, + track.Owner.Name, + track.Audio.TranscodeStatus, + track.DurationSeconds, + track.ListeningHistories.Count(listen => listen.IsCounted), + track.CreatedAt, + track.UpdatedAt, + track.OwnerUserId, + track.Tags.Select(tag => tag.Tag).ToList(), + track.IsExplicit)); + } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs index 146e1ac..024edf7 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs @@ -1,7 +1,8 @@ -namespace Musify.Application.Tracks.Responses; - -public record TrackStreamResponse( - string ManifestUrl, - string Ticket, - int ExpiresInSeconds, - Guid? ListenId = null); +namespace Musify.Application.Tracks.Responses +{ + public record TrackStreamResponse( + string ManifestUrl, + string Ticket, + int ExpiresInSeconds, + Guid? ListenId = null); +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs index e5b6106..8e1f5fb 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs @@ -1,15 +1,16 @@ -namespace Musify.Application.Tracks.Responses; - -public record TrackUploadUrlsResponse( - Guid PictureIntentId, - Guid AudioIntentId, - string Bucket, - string PictureKey, - string PictureName, - string PictureContentType, - string PictureUploadUrl, - string AudioKey, - string AudioName, - string AudioContentType, - string AudioUploadUrl, - int ExpiresInSeconds); +namespace Musify.Application.Tracks.Responses +{ + public record TrackUploadUrlsResponse( + Guid PictureIntentId, + Guid AudioIntentId, + string Bucket, + string PictureKey, + string PictureName, + string PictureContentType, + string PictureUploadUrl, + string AudioKey, + string AudioName, + string AudioContentType, + string AudioUploadUrl, + int ExpiresInSeconds); +} diff --git a/backend/Core/Musify.Application/Tracks/StartListening.cs b/backend/Core/Musify.Application/Tracks/StartListening.cs index ef3aeb1..73a51c7 100644 --- a/backend/Core/Musify.Application/Tracks/StartListening.cs +++ b/backend/Core/Musify.Application/Tracks/StartListening.cs @@ -11,46 +11,47 @@ using Musify.Application.Tracks.Responses; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks; - -/// Starts a listen: records it for signed-in users and issues the stream ticket. -public record StartListeningCommand(Guid TrackId, long? UserId) - : ICommand>; - -public class StartListeningCommandHandler( - IDatabase database, - TrackStreamIssuer streamIssuer, - PlaybackConfiguration playbackConfiguration, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.Tracks { - public async ValueTask> Handle(StartListeningCommand request, CancellationToken cancellationToken) + /// Starts a listen: records it for signed-in users and issues the stream ticket. + public record StartListeningCommand(Guid TrackId, long? UserId) + : ICommand>; + + public class StartListeningCommandHandler( + IDatabase database, + TrackStreamIssuer streamIssuer, + PlaybackConfiguration playbackConfiguration, + ILogger logger) + : ICommandHandler> { - if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening) - return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening."); - - var track = await database.Tracks.AsNoTracking() - .Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active) - .Select(t => new { t.Id, t.Audio }) - .SingleOrDefaultAsync(cancellationToken); - - if (track == null) - return AppErrors.NotFound("Track", request.TrackId); - - if (!track.Audio.IsProcessed) - return AppErrors.Conflict("Track.AudioNotReady", "Track audio is not available for streaming yet."); - - Guid? listenId = null; - if (request.UserId is { } userId) + public async ValueTask> Handle(StartListeningCommand request, CancellationToken cancellationToken) { - var listen = new ListeningHistory { UserId = userId, TrackId = track.Id }; - await database.ListeningHistories.AddAsync(listen, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - listenId = listen.Id; + if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening) + return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening."); + + var track = await database.Tracks.AsNoTracking() + .Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active) + .Select(t => new { t.Id, t.Audio }) + .SingleOrDefaultAsync(cancellationToken); + + if (track == null) + return AppErrors.NotFound("Track", request.TrackId); + + if (!track.Audio.IsProcessed) + return AppErrors.Conflict("Track.AudioNotReady", "Track audio is not available for streaming yet."); + + Guid? listenId = null; + if (request.UserId is { } userId) + { + var listen = new ListeningHistory { UserId = userId, TrackId = track.Id }; + await database.ListeningHistories.AddAsync(listen, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + listenId = listen.Id; + } + + logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString(CultureInfo.InvariantCulture) ?? "(anonymous)"); + + return streamIssuer.Issue(track.Audio.FolderName, request.UserId, listenId); } - - logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString(CultureInfo.InvariantCulture) ?? "(anonymous)"); - - return streamIssuer.Issue(track.Audio.FolderName, request.UserId, listenId); } } diff --git a/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs b/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs index 05fc48d..a27baa6 100644 --- a/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs +++ b/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs @@ -3,36 +3,37 @@ using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; -namespace Musify.Application.UploadIntents; - -/// Deletes temporary upload objects that nobody claimed within the retention window. -public record DeleteStaleTempUploadsCommand : ICommand; - -public class DeleteStaleTempUploadsCommandHandler( - IStorageService storageService, - ApplicationStorageConfiguration storageConfiguration, - UploadIntentConfiguration uploadIntentConfiguration, - ILogger logger) - : ICommandHandler +namespace Musify.Application.UploadIntents { - public async ValueTask Handle(DeleteStaleTempUploadsCommand request, CancellationToken cancellationToken) + /// Deletes temporary upload objects that nobody claimed within the retention window. + public record DeleteStaleTempUploadsCommand : ICommand; + + public class DeleteStaleTempUploadsCommandHandler( + IStorageService storageService, + ApplicationStorageConfiguration storageConfiguration, + UploadIntentConfiguration uploadIntentConfiguration, + ILogger logger) + : ICommandHandler { - var cutOff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TempUploadsRetentionDays); - var deleted = 0; - - await foreach (var (key, lastModified) in storageService.ListObjectsAsync( - storageConfiguration.Bucket, uploadIntentConfiguration.TempRootPrefix, cancellationToken)) + public async ValueTask Handle(DeleteStaleTempUploadsCommand request, CancellationToken cancellationToken) { - if (lastModified >= cutOff) - continue; + var cutOff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TempUploadsRetentionDays); + var deleted = 0; - await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken); - deleted++; + await foreach (var (key, lastModified) in storageService.ListObjectsAsync( + storageConfiguration.Bucket, uploadIntentConfiguration.TempRootPrefix, cancellationToken)) + { + if (lastModified >= cutOff) + continue; + + await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken); + deleted++; + } + + if (deleted > 0) + logger.LogInformation("Deleted {Deleted} orphaned temp uploads", deleted); + + return deleted; } - - if (deleted > 0) - logger.LogInformation("Deleted {Deleted} orphaned temp uploads", deleted); - - return deleted; } } diff --git a/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs b/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs index 9b6e3d2..1759bfd 100644 --- a/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs +++ b/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs @@ -5,42 +5,43 @@ using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Domain.ValueObjects; -namespace Musify.Application.UploadIntents; - -/// Marks overdue upload intents as expired and deletes the ones expired for longer than the retention window. -public record ExpireUploadIntentsCommand : ICommand; - -public class ExpireUploadIntentsCommandHandler( - IDatabase database, - UploadIntentConfiguration uploadIntentConfiguration, - ILogger logger) - : ICommandHandler +namespace Musify.Application.UploadIntents { - public async ValueTask Handle(ExpireUploadIntentsCommand request, CancellationToken cancellationToken) + /// Marks overdue upload intents as expired and deletes the ones expired for longer than the retention window. + public record ExpireUploadIntentsCommand : ICommand; + + public class ExpireUploadIntentsCommandHandler( + IDatabase database, + UploadIntentConfiguration uploadIntentConfiguration, + ILogger logger) + : ICommandHandler { - var now = DateTime.UtcNow; + public async ValueTask Handle(ExpireUploadIntentsCommand request, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; - var expiredIntents = await database.UploadIntents - .Where(intent => intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt < now) - .ToListAsync(cancellationToken); + var expiredIntents = await database.UploadIntents + .Where(intent => intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt < now) + .ToListAsync(cancellationToken); - foreach (var intent in expiredIntents) - intent.Status = UploadIntentStatus.Expired; + foreach (var intent in expiredIntents) + intent.Status = UploadIntentStatus.Expired; - var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays); - var oldExpiredIntents = await database.UploadIntents - .Where(intent => intent.Status == UploadIntentStatus.Expired && intent.ExpiresAt < retentionCutoff) - .ToListAsync(cancellationToken); + var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays); + var oldExpiredIntents = await database.UploadIntents + .Where(intent => intent.Status == UploadIntentStatus.Expired && intent.ExpiresAt < retentionCutoff) + .ToListAsync(cancellationToken); - database.UploadIntents.RemoveRange(oldExpiredIntents); + database.UploadIntents.RemoveRange(oldExpiredIntents); - if (expiredIntents.Count == 0 && oldExpiredIntents.Count == 0) - return 0; + if (expiredIntents.Count == 0 && oldExpiredIntents.Count == 0) + return 0; - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Marked {Expired} upload intents as expired and deleted {Deleted} old ones", - expiredIntents.Count, oldExpiredIntents.Count); + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Marked {Expired} upload intents as expired and deleted {Deleted} old ones", + expiredIntents.Count, oldExpiredIntents.Count); - return expiredIntents.Count + oldExpiredIntents.Count; + return expiredIntents.Count + oldExpiredIntents.Count; + } } } diff --git a/backend/Core/Musify.Application/Users/FollowUser.cs b/backend/Core/Musify.Application/Users/FollowUser.cs index bcb4441..029b2ee 100644 --- a/backend/Core/Musify.Application/Users/FollowUser.cs +++ b/backend/Core/Musify.Application/Users/FollowUser.cs @@ -6,46 +6,47 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Domain.Entities; -namespace Musify.Application.Users; - -public record FollowUserCommand(long FollowerId, long FollowedId) : ICommand>; - -public class FollowUserCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.Users { - public async ValueTask> Handle(FollowUserCommand request, CancellationToken cancellationToken) + public record FollowUserCommand(long FollowerId, long FollowedId) : ICommand>; + + public class FollowUserCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - if (request.FollowerId == request.FollowedId) - return Error.Validation(description: "A user cannot follow themselves."); - - var followedExists = await database.Users - .AsNoTracking() - .AnyAsync(u => u.Id == request.FollowedId, cancellationToken); - if (!followedExists) - return AppErrors.NotFound("User", request.FollowedId); - - var alreadyFollowing = await database.UserFollows - .AsNoTracking() - .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); - if (alreadyFollowing) - return Result.Success; - - await database.UserFollows.AddAsync(new UserFollow + public async ValueTask> Handle(FollowUserCommand request, CancellationToken cancellationToken) { - FollowerId = request.FollowerId, - FollowedId = request.FollowedId - }, cancellationToken); + if (request.FollowerId == request.FollowedId) + return Error.Validation(description: "A user cannot follow themselves."); + + var followedExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.FollowedId, cancellationToken); + if (!followedExists) + return AppErrors.NotFound("User", request.FollowedId); + + var alreadyFollowing = await database.UserFollows + .AsNoTracking() + .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); + if (alreadyFollowing) + return Result.Success; + + await database.UserFollows.AddAsync(new UserFollow + { + FollowerId = request.FollowerId, + FollowedId = request.FollowedId + }, cancellationToken); + + // A concurrent double click can hit the unique index; already following is a successful no-op. + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("UserFollow.Duplicate", "Already following this user."), cancellationToken); + if (saved.IsError) + return Result.Success; + + logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId); - // A concurrent double click can hit the unique index; already following is a successful no-op. - var saved = await database.TrySaveChangesAsync( - AppErrors.Conflict("UserFollow.Duplicate", "Already following this user."), cancellationToken); - if (saved.IsError) return Result.Success; - - logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId); - - return Result.Success; + } } } diff --git a/backend/Core/Musify.Application/Users/FollowVisibility.cs b/backend/Core/Musify.Application/Users/FollowVisibility.cs index 573e1a3..b3c45a8 100644 --- a/backend/Core/Musify.Application/Users/FollowVisibility.cs +++ b/backend/Core/Musify.Application/Users/FollowVisibility.cs @@ -1,29 +1,30 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; -namespace Musify.Application.Users; - -public static class FollowVisibility +namespace Musify.Application.Users { - public static async Task CanViewFollowersAsync( - IDatabase database, - long? viewerId, - long userId, - CancellationToken cancellationToken) + public static class FollowVisibility { - if (viewerId is not { } viewer) - return false; + public static async Task CanViewFollowersAsync( + IDatabase database, + long? viewerId, + long userId, + CancellationToken cancellationToken) + { + if (viewerId is not { } viewer) + return false; - if (viewer == userId) - return true; + if (viewer == userId) + return true; - var mutualRows = await database.UserFollows - .AsNoTracking() - .CountAsync(f => - (f.FollowerId == viewer && f.FollowedId == userId) || - (f.FollowerId == userId && f.FollowedId == viewer), - cancellationToken); + var mutualRows = await database.UserFollows + .AsNoTracking() + .CountAsync(f => + (f.FollowerId == viewer && f.FollowedId == userId) || + (f.FollowerId == userId && f.FollowedId == viewer), + cancellationToken); - return mutualRows == 2; + return mutualRows == 2; + } } } diff --git a/backend/Core/Musify.Application/Users/GetListeningStats.cs b/backend/Core/Musify.Application/Users/GetListeningStats.cs index a1fcd1d..981c474 100644 --- a/backend/Core/Musify.Application/Users/GetListeningStats.cs +++ b/backend/Core/Musify.Application/Users/GetListeningStats.cs @@ -3,50 +3,51 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Users.Responses; -namespace Musify.Application.Users; - -public record GetListeningStatsQuery(long UserId) : IQuery; - -public class GetListeningStatsQueryHandler(IDatabase database) - : IQueryHandler +namespace Musify.Application.Users { - public async ValueTask Handle(GetListeningStatsQuery request, CancellationToken cancellationToken) + public record GetListeningStatsQuery(long UserId) : IQuery; + + public class GetListeningStatsQueryHandler(IDatabase database) + : IQueryHandler { - var weekAgo = DateTime.UtcNow.AddDays(-7); - - var thisWeekHistory = database.ListeningHistories - .AsNoTracking() - .Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo); - - var tracksThisWeek = await thisWeekHistory - .Where(l => l.IsCounted) - .Select(l => l.TrackId) - .Distinct() - .CountAsync(cancellationToken); - - var secondsThisWeek = await thisWeekHistory - .Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => l.PlayedSeconds ?? (l.IsCounted ? t.DurationSeconds : 0)) - .SumAsync(cancellationToken); - - var listenedDates = await database.ListeningHistories - .AsNoTracking() - .Where(l => l.UserId == request.UserId && l.IsCounted) - .Select(l => l.ListenedAt.Date) - .Distinct() - .ToListAsync(cancellationToken); - - var listenedDateSet = listenedDates.ToHashSet(); - var streakDays = 0; - var day = DateTime.UtcNow.Date; - if (!listenedDateSet.Contains(day)) - day = day.AddDays(-1); - - while (listenedDateSet.Contains(day)) + public async ValueTask Handle(GetListeningStatsQuery request, CancellationToken cancellationToken) { - streakDays++; - day = day.AddDays(-1); - } + var weekAgo = DateTime.UtcNow.AddDays(-7); - return new ListeningStatsResponse(tracksThisWeek, (int)Math.Round(secondsThisWeek), streakDays); + var thisWeekHistory = database.ListeningHistories + .AsNoTracking() + .Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo); + + var tracksThisWeek = await thisWeekHistory + .Where(l => l.IsCounted) + .Select(l => l.TrackId) + .Distinct() + .CountAsync(cancellationToken); + + var secondsThisWeek = await thisWeekHistory + .Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => l.PlayedSeconds ?? (l.IsCounted ? t.DurationSeconds : 0)) + .SumAsync(cancellationToken); + + var listenedDates = await database.ListeningHistories + .AsNoTracking() + .Where(l => l.UserId == request.UserId && l.IsCounted) + .Select(l => l.ListenedAt.Date) + .Distinct() + .ToListAsync(cancellationToken); + + var listenedDateSet = listenedDates.ToHashSet(); + var streakDays = 0; + var day = DateTime.UtcNow.Date; + if (!listenedDateSet.Contains(day)) + day = day.AddDays(-1); + + while (listenedDateSet.Contains(day)) + { + streakDays++; + day = day.AddDays(-1); + } + + return new ListeningStatsResponse(tracksThisWeek, (int)Math.Round(secondsThisWeek), streakDays); + } } } diff --git a/backend/Core/Musify.Application/Users/GetUserById.cs b/backend/Core/Musify.Application/Users/GetUserById.cs index 0d6eab3..dc009bc 100644 --- a/backend/Core/Musify.Application/Users/GetUserById.cs +++ b/backend/Core/Musify.Application/Users/GetUserById.cs @@ -5,24 +5,25 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -namespace Musify.Application.Users; - -public record GetUserByIdQuery(long Id) - : IQuery>; - -public class GetUserByIdQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Users { - public async ValueTask> Handle(GetUserByIdQuery request, CancellationToken cancellationToken) - { - var user = await database.Users - .AsNoTracking() - .Where(u => u.Id == request.Id) - .Select(u => UserApplicationResponse.FromEntity(u)) - .SingleOrDefaultAsync(cancellationToken); + public record GetUserByIdQuery(long Id) + : IQuery>; - return user == null - ? AppErrors.NotFound("User", request.Id) - : user; + public class GetUserByIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetUserByIdQuery request, CancellationToken cancellationToken) + { + var user = await database.Users + .AsNoTracking() + .Where(u => u.Id == request.Id) + .Select(u => UserApplicationResponse.FromEntity(u)) + .SingleOrDefaultAsync(cancellationToken); + + return user == null + ? AppErrors.NotFound("User", request.Id) + : user; + } } } diff --git a/backend/Core/Musify.Application/Users/GetUserFollowers.cs b/backend/Core/Musify.Application/Users/GetUserFollowers.cs index 90284fb..9759352 100644 --- a/backend/Core/Musify.Application/Users/GetUserFollowers.cs +++ b/backend/Core/Musify.Application/Users/GetUserFollowers.cs @@ -5,36 +5,37 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -namespace Musify.Application.Users; - -public record GetUserFollowersQuery(long? ViewerId, long UserId, int PageNumber, int PageSize) - : IQuery>>; - -public class GetUserFollowersQueryHandler(IDatabase database) - : IQueryHandler>> +namespace Musify.Application.Users { - public async ValueTask>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken) + public record GetUserFollowersQuery(long? ViewerId, long UserId, int PageNumber, int PageSize) + : IQuery>>; + + public class GetUserFollowersQueryHandler(IDatabase database) + : IQueryHandler>> { - if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) - return AppErrors.NotFound("User", request.UserId); + public async ValueTask>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken) + { + if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) + return AppErrors.NotFound("User", request.UserId); - if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken)) - return AppErrors.Forbidden("User", request.UserId); + if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken)) + return AppErrors.Forbidden("User", request.UserId); - var viewerId = request.ViewerId; + var viewerId = request.ViewerId; - return await database.UserFollows - .AsNoTracking() - .Where(f => f.FollowedId == request.UserId) - .OrderByDescending(f => f.CreatedAt) - .ThenBy(f => f.Id) - .Select(f => new UserSummaryResponse( - f.Follower.Id, - f.Follower.Name, - f.Follower.FirstName, - f.Follower.SecondName, - f.Follower.ProfilePictureUrl, - viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId))) - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + return await database.UserFollows + .AsNoTracking() + .Where(f => f.FollowedId == request.UserId) + .OrderByDescending(f => f.CreatedAt) + .ThenBy(f => f.Id) + .Select(f => new UserSummaryResponse( + f.Follower.Id, + f.Follower.Name, + f.Follower.FirstName, + f.Follower.SecondName, + f.Follower.ProfilePictureUrl, + viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId))) + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/Users/GetUserFollowing.cs b/backend/Core/Musify.Application/Users/GetUserFollowing.cs index 50c7c1b..ae2cb84 100644 --- a/backend/Core/Musify.Application/Users/GetUserFollowing.cs +++ b/backend/Core/Musify.Application/Users/GetUserFollowing.cs @@ -5,33 +5,34 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -namespace Musify.Application.Users; - -public record GetUserFollowingQuery(long? ViewerId, long UserId, int PageNumber, int PageSize) - : IQuery>>; - -public class GetUserFollowingQueryHandler(IDatabase database) - : IQueryHandler>> +namespace Musify.Application.Users { - public async ValueTask>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken) + public record GetUserFollowingQuery(long? ViewerId, long UserId, int PageNumber, int PageSize) + : IQuery>>; + + public class GetUserFollowingQueryHandler(IDatabase database) + : IQueryHandler>> { - if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) - return AppErrors.NotFound("User", request.UserId); + public async ValueTask>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken) + { + if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) + return AppErrors.NotFound("User", request.UserId); - var viewerId = request.ViewerId; + var viewerId = request.ViewerId; - return await database.UserFollows - .AsNoTracking() - .Where(f => f.FollowerId == request.UserId) - .OrderByDescending(f => f.CreatedAt) - .ThenBy(f => f.Id) - .Select(f => new UserSummaryResponse( - f.Followed.Id, - f.Followed.Name, - f.Followed.FirstName, - f.Followed.SecondName, - f.Followed.ProfilePictureUrl, - viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId))) - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + return await database.UserFollows + .AsNoTracking() + .Where(f => f.FollowerId == request.UserId) + .OrderByDescending(f => f.CreatedAt) + .ThenBy(f => f.Id) + .Select(f => new UserSummaryResponse( + f.Followed.Id, + f.Followed.Name, + f.Followed.FirstName, + f.Followed.SecondName, + f.Followed.ProfilePictureUrl, + viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId))) + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/Users/GetUserProfile.cs b/backend/Core/Musify.Application/Users/GetUserProfile.cs index cf9796e..51ad5dd 100644 --- a/backend/Core/Musify.Application/Users/GetUserProfile.cs +++ b/backend/Core/Musify.Application/Users/GetUserProfile.cs @@ -5,58 +5,59 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -namespace Musify.Application.Users; - -public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery>; - -public class GetUserProfileQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Users { - public async ValueTask> Handle(GetUserProfileQuery request, CancellationToken cancellationToken) + public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery>; + + public class GetUserProfileQueryHandler(IDatabase database) + : IQueryHandler> { - var user = await database.Users - .AsNoTracking() - .Where(u => u.Id == request.UserId) - .Select(u => new - { - u.Id, - u.Name, - u.FirstName, - u.SecondName, - u.ProfilePictureUrl, - u.CreatedAt - }) - .SingleOrDefaultAsync(cancellationToken); - - if (user == null) - return AppErrors.NotFound("User", request.UserId); - - var followersCount = await database.UserFollows - .AsNoTracking() - .CountAsync(f => f.FollowedId == request.UserId, cancellationToken); - - var followingCount = await database.UserFollows - .AsNoTracking() - .CountAsync(f => f.FollowerId == request.UserId, cancellationToken); - - var isFollowing = request.ViewerId is { } viewerId && viewerId != request.UserId && - await database.UserFollows + public async ValueTask> Handle(GetUserProfileQuery request, CancellationToken cancellationToken) + { + var user = await database.Users .AsNoTracking() - .AnyAsync(f => f.FollowerId == viewerId && f.FollowedId == request.UserId, cancellationToken); + .Where(u => u.Id == request.UserId) + .Select(u => new + { + u.Id, + u.Name, + u.FirstName, + u.SecondName, + u.ProfilePictureUrl, + u.CreatedAt + }) + .SingleOrDefaultAsync(cancellationToken); - var canViewFollowers = await FollowVisibility.CanViewFollowersAsync( - database, request.ViewerId, request.UserId, cancellationToken); + if (user == null) + return AppErrors.NotFound("User", request.UserId); - return new UserProfileResponse( - user.Id, - user.Name, - user.FirstName, - user.SecondName, - user.ProfilePictureUrl, - user.CreatedAt, - followersCount, - followingCount, - isFollowing, - canViewFollowers); + var followersCount = await database.UserFollows + .AsNoTracking() + .CountAsync(f => f.FollowedId == request.UserId, cancellationToken); + + var followingCount = await database.UserFollows + .AsNoTracking() + .CountAsync(f => f.FollowerId == request.UserId, cancellationToken); + + var isFollowing = request.ViewerId is { } viewerId && viewerId != request.UserId && + await database.UserFollows + .AsNoTracking() + .AnyAsync(f => f.FollowerId == viewerId && f.FollowedId == request.UserId, cancellationToken); + + var canViewFollowers = await FollowVisibility.CanViewFollowersAsync( + database, request.ViewerId, request.UserId, cancellationToken); + + return new UserProfileResponse( + user.Id, + user.Name, + user.FirstName, + user.SecondName, + user.ProfilePictureUrl, + user.CreatedAt, + followersCount, + followingCount, + isFollowing, + canViewFollowers); + } } } diff --git a/backend/Core/Musify.Application/Users/GetUsers.cs b/backend/Core/Musify.Application/Users/GetUsers.cs index 1dcffd7..d839c73 100644 --- a/backend/Core/Musify.Application/Users/GetUsers.cs +++ b/backend/Core/Musify.Application/Users/GetUsers.cs @@ -4,37 +4,38 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -namespace Musify.Application.Users; - -public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null) - : IQuery>; - -public class GetUsersQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Users { - public async ValueTask> Handle(GetUsersQuery request, CancellationToken cancellationToken) + public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null) + : IQuery>; + + public class GetUsersQueryHandler(IDatabase database) + : IQueryHandler> { - var usersQuery = database.Users - .AsNoTracking(); - - if (!string.IsNullOrWhiteSpace(request.UsernameSearch)) + public async ValueTask> Handle(GetUsersQuery request, CancellationToken cancellationToken) { - var normalizedUsername = TextNormalizer.Normalize(request.UsernameSearch); - usersQuery = usersQuery.Where(user => user.NormalizedName.Contains(normalizedUsername)); + var usersQuery = database.Users + .AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(request.UsernameSearch)) + { + var normalizedUsername = TextNormalizer.Normalize(request.UsernameSearch); + usersQuery = usersQuery.Where(user => user.NormalizedName.Contains(normalizedUsername)); + } + + var viewerId = request.ViewerId; + + return await usersQuery + .OrderBy(user => user.Name) + .ThenBy(user => user.Id) + .Select(user => new UserSummaryResponse( + user.Id, + user.Name, + user.FirstName, + user.SecondName, + user.ProfilePictureUrl, + viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == user.Id))) + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } - - var viewerId = request.ViewerId; - - return await usersQuery - .OrderBy(user => user.Name) - .ThenBy(user => user.Id) - .Select(user => new UserSummaryResponse( - user.Id, - user.Name, - user.FirstName, - user.SecondName, - user.ProfilePictureUrl, - viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == user.Id))) - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Users/IsFollowingUser.cs b/backend/Core/Musify.Application/Users/IsFollowingUser.cs index 44b9ca9..a8e9a2a 100644 --- a/backend/Core/Musify.Application/Users/IsFollowingUser.cs +++ b/backend/Core/Musify.Application/Users/IsFollowingUser.cs @@ -2,15 +2,16 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; -namespace Musify.Application.Users; - -public record IsFollowingUserQuery(long FollowerId, long FollowedId) : IQuery; - -public class IsFollowingUserQueryHandler(IDatabase database) - : IQueryHandler +namespace Musify.Application.Users { - public ValueTask Handle(IsFollowingUserQuery request, CancellationToken cancellationToken) => - new(database.UserFollows - .AsNoTracking() - .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken)); + public record IsFollowingUserQuery(long FollowerId, long FollowedId) : IQuery; + + public class IsFollowingUserQueryHandler(IDatabase database) + : IQueryHandler + { + public ValueTask Handle(IsFollowingUserQuery request, CancellationToken cancellationToken) => + new(database.UserFollows + .AsNoTracking() + .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken)); + } } diff --git a/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs b/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs index c12d7ce..b632205 100644 --- a/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Users.Responses; - -public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays); +namespace Musify.Application.Users.Responses +{ + public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays); +} diff --git a/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs index 2ab2b7b..a96a88a 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs @@ -1,25 +1,26 @@ using Musify.Domain.Entities; -namespace Musify.Application.Users.Responses; - -public record UserApplicationResponse( - long Id, - string Name, - string? FirstName, - string? SecondName, - string? ProfilePictureUrl, - DateTime CreatedAt, - DateTime UpdatedAt) +namespace Musify.Application.Users.Responses { - public static UserApplicationResponse FromEntity(User user) + public record UserApplicationResponse( + long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl, + DateTime CreatedAt, + DateTime UpdatedAt) { - return new UserApplicationResponse( - user.Id, - user.Name, - user.FirstName, - user.SecondName, - user.ProfilePictureUrl, - user.CreatedAt, - user.UpdatedAt); + public static UserApplicationResponse FromEntity(User user) + { + return new UserApplicationResponse( + user.Id, + user.Name, + user.FirstName, + user.SecondName, + user.ProfilePictureUrl, + user.CreatedAt, + user.UpdatedAt); + } } } diff --git a/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs index 1c3bf41..de3252b 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs @@ -1,14 +1,15 @@ -namespace Musify.Application.Users.Responses; - -public record UserProfileResponse( - long Id, - string Name, - string? FirstName, - string? SecondName, - string? ProfilePictureUrl, - DateTime CreatedAt, - int FollowersCount, - int FollowingCount, - bool IsFollowing, - bool CanViewFollowers); +namespace Musify.Application.Users.Responses +{ + public record UserProfileResponse( + long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl, + DateTime CreatedAt, + int FollowersCount, + int FollowingCount, + bool IsFollowing, + bool CanViewFollowers); +} diff --git a/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs index 34651c6..cf08570 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs @@ -1,10 +1,11 @@ -namespace Musify.Application.Users.Responses; - -public record UserSummaryResponse( - long Id, - string Name, - string? FirstName, - string? SecondName, - string? ProfilePictureUrl, - bool IsFollowedByViewer); +namespace Musify.Application.Users.Responses +{ + public record UserSummaryResponse( + long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl, + bool IsFollowedByViewer); +} diff --git a/backend/Core/Musify.Application/Users/SyncUser.cs b/backend/Core/Musify.Application/Users/SyncUser.cs index ff5c6ac..4b15cb9 100644 --- a/backend/Core/Musify.Application/Users/SyncUser.cs +++ b/backend/Core/Musify.Application/Users/SyncUser.cs @@ -6,52 +6,53 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Domain.Entities; -namespace Musify.Application.Users; - -public record SyncUserCommand( - long Id, - string Name, - string? FirstName, - string? SecondName, - string? ProfilePictureUrl) - : ICommand>; - -public class SyncUserCommandHandler(IDatabase database, ILogger logger) - : ICommandHandler> +namespace Musify.Application.Users { - public async ValueTask> Handle(SyncUserCommand request, CancellationToken cancellationToken) + public record SyncUserCommand( + long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl) + : ICommand>; + + public class SyncUserCommandHandler(IDatabase database, ILogger logger) + : ICommandHandler> { - var user = await database.Users - .SingleOrDefaultAsync(u => u.Id == request.Id, cancellationToken); - - if (user == null) + public async ValueTask> Handle(SyncUserCommand request, CancellationToken cancellationToken) { - await database.Users.AddAsync(new User - { - Id = request.Id, - Name = request.Name, - NormalizedName = TextNormalizer.Normalize(request.Name), - FirstName = request.FirstName, - SecondName = request.SecondName, - ProfilePictureUrl = request.ProfilePictureUrl - }, cancellationToken); + var user = await database.Users + .SingleOrDefaultAsync(u => u.Id == request.Id, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Provisioned user {UserId}", request.Id); + if (user == null) + { + await database.Users.AddAsync(new User + { + Id = request.Id, + Name = request.Name, + NormalizedName = TextNormalizer.Normalize(request.Name), + FirstName = request.FirstName, + SecondName = request.SecondName, + ProfilePictureUrl = request.ProfilePictureUrl + }, cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Provisioned user {UserId}", request.Id); + + return Result.Success; + } + + user.Name = request.Name; + user.NormalizedName = TextNormalizer.Normalize(request.Name); + user.FirstName = request.FirstName; + user.SecondName = request.SecondName; + user.ProfilePictureUrl = request.ProfilePictureUrl; + + var written = await database.SaveChangesAsync(cancellationToken); + if (written > 0) + logger.LogInformation("Synced profile for user {UserId}", request.Id); return Result.Success; } - - user.Name = request.Name; - user.NormalizedName = TextNormalizer.Normalize(request.Name); - user.FirstName = request.FirstName; - user.SecondName = request.SecondName; - user.ProfilePictureUrl = request.ProfilePictureUrl; - - var written = await database.SaveChangesAsync(cancellationToken); - if (written > 0) - logger.LogInformation("Synced profile for user {UserId}", request.Id); - - return Result.Success; } } diff --git a/backend/Core/Musify.Application/Users/UnfollowUser.cs b/backend/Core/Musify.Application/Users/UnfollowUser.cs index 49c24d6..5d4ea87 100644 --- a/backend/Core/Musify.Application/Users/UnfollowUser.cs +++ b/backend/Core/Musify.Application/Users/UnfollowUser.cs @@ -4,28 +4,29 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -namespace Musify.Application.Users; - -public record UnfollowUserCommand(long FollowerId, long FollowedId) : ICommand>; - -public class UnfollowUserCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.Users { - public async ValueTask> Handle(UnfollowUserCommand request, CancellationToken cancellationToken) + public record UnfollowUserCommand(long FollowerId, long FollowedId) : ICommand>; + + public class UnfollowUserCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - var follow = await database.UserFollows - .SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); + public async ValueTask> Handle(UnfollowUserCommand request, CancellationToken cancellationToken) + { + var follow = await database.UserFollows + .SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); + + if (follow == null) + return Result.Success; + + database.UserFollows.Remove(follow); + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId); - if (follow == null) return Result.Success; - - database.UserFollows.Remove(follow); - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId); - - return Result.Success; + } } } diff --git a/backend/Core/Musify.Domain/Abstractions/IAuditable.cs b/backend/Core/Musify.Domain/Abstractions/IAuditable.cs index b837a31..bbba395 100644 --- a/backend/Core/Musify.Domain/Abstractions/IAuditable.cs +++ b/backend/Core/Musify.Domain/Abstractions/IAuditable.cs @@ -1,8 +1,9 @@ -namespace Musify.Domain.Abstractions; - -public interface IAuditable +namespace Musify.Domain.Abstractions { - public DateTime CreatedAt { get; set; } + public interface IAuditable + { + public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } } diff --git a/backend/Core/Musify.Domain/Abstractions/IHasLifeCycle.cs b/backend/Core/Musify.Domain/Abstractions/IHasLifeCycle.cs index 6020728..0bcd88d 100644 --- a/backend/Core/Musify.Domain/Abstractions/IHasLifeCycle.cs +++ b/backend/Core/Musify.Domain/Abstractions/IHasLifeCycle.cs @@ -1,10 +1,11 @@ using Musify.Domain.ValueObjects; -namespace Musify.Domain.Abstractions; - -public interface IHasLifeCycle +namespace Musify.Domain.Abstractions { - public Guid Id { get; } + public interface IHasLifeCycle + { + public Guid Id { get; } - public LifeCycleStatus LifeCycleStatus { get; set; } + public LifeCycleStatus LifeCycleStatus { get; set; } + } } diff --git a/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs b/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs index d65c9fb..944c211 100644 --- a/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs +++ b/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs @@ -1,6 +1,7 @@ -namespace Musify.Domain.Abstractions; - -public interface IOwnedEntity +namespace Musify.Domain.Abstractions { - public long OwnerUserId { get; } + public interface IOwnedEntity + { + public long OwnerUserId { get; } + } } diff --git a/backend/Core/Musify.Domain/Entities/Album.cs b/backend/Core/Musify.Domain/Entities/Album.cs index 0cfeca1..8542293 100644 --- a/backend/Core/Musify.Domain/Entities/Album.cs +++ b/backend/Core/Musify.Domain/Entities/Album.cs @@ -3,37 +3,38 @@ using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities; - -[Table("Albums")] -public class Album : IAuditable, IHasLifeCycle, IOwnedEntity +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("Albums")] + public class Album : IAuditable, IHasLifeCycle, IOwnedEntity + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [MaxLength(200)] - public required string Title { get; set; } + [MaxLength(200)] + public required string Title { get; set; } - [MaxLength(200)] - public required string NormalizedTitle { get; set; } + [MaxLength(200)] + public required string NormalizedTitle { get; set; } - [MaxLength(256)] - public string? Description { get; set; } + [MaxLength(256)] + public string? Description { get; set; } - public int? ReleaseYear { get; set; } + public int? ReleaseYear { get; set; } - public EntityPictures? Pictures { get; set; } + public EntityPictures? Pictures { get; set; } - public required long OwnerUserId { get; set; } + public required long OwnerUserId { get; set; } - [ForeignKey(nameof(OwnerUserId))] - public User Owner { get; set; } = null!; + [ForeignKey(nameof(OwnerUserId))] + public User Owner { get; set; } = null!; - public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; + public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - public ICollection AlbumTracks { get; set; } = new List(); + public ICollection AlbumTracks { get; set; } = new List(); + } } diff --git a/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs b/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs index 3648390..cb90a5d 100644 --- a/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs +++ b/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs @@ -1,23 +1,24 @@ using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities; - -[Table("AlbumHasTrack")] -public class AlbumHasTrack +namespace Musify.Domain.Entities { - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("AlbumHasTrack")] + public class AlbumHasTrack + { + public Guid Id { get; set; } = Guid.NewGuid(); - public required Guid AlbumId { get; set; } + public required Guid AlbumId { get; set; } - public required Guid TrackId { get; set; } + public required Guid TrackId { get; set; } - public int TrackNumber { get; set; } = 1; + public int TrackNumber { get; set; } = 1; - public DateTime AddedAt { get; set; } = DateTime.UtcNow; + public DateTime AddedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(AlbumId))] - public Album Album { get; set; } = null!; + [ForeignKey(nameof(AlbumId))] + public Album Album { get; set; } = null!; - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } = null!; + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } = null!; + } } diff --git a/backend/Core/Musify.Domain/Entities/ListeningHistory.cs b/backend/Core/Musify.Domain/Entities/ListeningHistory.cs index 4a1afd1..48ecefe 100644 --- a/backend/Core/Musify.Domain/Entities/ListeningHistory.cs +++ b/backend/Core/Musify.Domain/Entities/ListeningHistory.cs @@ -1,29 +1,30 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities; - -[Table("ListeningHistory")] -public class ListeningHistory +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("ListeningHistory")] + public class ListeningHistory + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - public required long UserId { get; set; } + public required long UserId { get; set; } - public required Guid TrackId { get; set; } + public required Guid TrackId { get; set; } - public DateTime ListenedAt { get; set; } = DateTime.UtcNow; + public DateTime ListenedAt { get; set; } = DateTime.UtcNow; - public double? PlayedSeconds { get; set; } + public double? PlayedSeconds { get; set; } - public DateTime? LastProgressAt { get; set; } + public DateTime? LastProgressAt { get; set; } - public bool IsCounted { get; set; } + public bool IsCounted { get; set; } - [ForeignKey(nameof(UserId))] - public User User { get; set; } = null!; + [ForeignKey(nameof(UserId))] + public User User { get; set; } = null!; - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } = null!; + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } = null!; + } } diff --git a/backend/Core/Musify.Domain/Entities/Mix.cs b/backend/Core/Musify.Domain/Entities/Mix.cs index 4951abe..18ec1d3 100644 --- a/backend/Core/Musify.Domain/Entities/Mix.cs +++ b/backend/Core/Musify.Domain/Entities/Mix.cs @@ -3,26 +3,27 @@ using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities; - -[Table("Mixes")] -public class Mix : IAuditable +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("Mixes")] + public class Mix : IAuditable + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - public required long UserId { get; set; } + public required long UserId { get; set; } - public MixKind Kind { get; set; } + public MixKind Kind { get; set; } - public int Position { get; set; } + public int Position { get; set; } - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(UserId))] - public User User { get; set; } = null!; + [ForeignKey(nameof(UserId))] + public User User { get; set; } = null!; - public ICollection Items { get; set; } = new List(); + public ICollection Items { get; set; } = new List(); + } } diff --git a/backend/Core/Musify.Domain/Entities/MixItem.cs b/backend/Core/Musify.Domain/Entities/MixItem.cs index 278e2b8..844da15 100644 --- a/backend/Core/Musify.Domain/Entities/MixItem.cs +++ b/backend/Core/Musify.Domain/Entities/MixItem.cs @@ -1,23 +1,24 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities; - -[Table("MixItems")] -public class MixItem +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("MixItems")] + public class MixItem + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - public required Guid MixId { get; set; } + public required Guid MixId { get; set; } - public int Position { get; set; } + public int Position { get; set; } - public required Guid TrackId { get; set; } + public required Guid TrackId { get; set; } - [ForeignKey(nameof(MixId))] - public Mix Mix { get; set; } = null!; + [ForeignKey(nameof(MixId))] + public Mix Mix { get; set; } = null!; - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } = null!; + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } = null!; + } } diff --git a/backend/Core/Musify.Domain/Entities/PlayList.cs b/backend/Core/Musify.Domain/Entities/PlayList.cs index 7774958..5a31a9e 100644 --- a/backend/Core/Musify.Domain/Entities/PlayList.cs +++ b/backend/Core/Musify.Domain/Entities/PlayList.cs @@ -4,38 +4,39 @@ using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities; - -[Table("PlayLists")] -public class PlayList : IAuditable, IHasLifeCycle, IOwnedEntity +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("PlayLists")] + public class PlayList : IAuditable, IHasLifeCycle, IOwnedEntity + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [Column("UserId")] - public required long OwnerUserId { get; set; } + [Column("UserId")] + public required long OwnerUserId { get; set; } - [MaxLength(50)] - public required string Name { get; set; } + [MaxLength(50)] + public required string Name { get; set; } - [MaxLength(50)] - public required string NormalizedName { get; set; } + [MaxLength(50)] + public required string NormalizedName { get; set; } - [MaxLength(256)] - public string? Description { get; set; } + [MaxLength(256)] + public string? Description { get; set; } - public EntityPictures? Pictures { get; set; } + public EntityPictures? Pictures { get; set; } - public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; + public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; - public PlayListVisibility Visibility { get; set; } = PlayListVisibility.Private; + public PlayListVisibility Visibility { get; set; } = PlayListVisibility.Private; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(OwnerUserId))] - public User Owner { get; set; } = null!; + [ForeignKey(nameof(OwnerUserId))] + public User Owner { get; set; } = null!; - public ICollection PlayListTracks { get; set; } = new List(); + public ICollection PlayListTracks { get; set; } = new List(); + } } diff --git a/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs b/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs index 173bf5b..a8e2c33 100644 --- a/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs +++ b/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs @@ -1,23 +1,24 @@ using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities; - -[Table("PlayListHasTrack")] -public class PlayListHasTrack +namespace Musify.Domain.Entities { - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("PlayListHasTrack")] + public class PlayListHasTrack + { + public Guid Id { get; set; } = Guid.NewGuid(); - public required Guid PlayListId { get; set; } + public required Guid PlayListId { get; set; } - public required Guid TrackId { get; set; } + public required Guid TrackId { get; set; } - public int Position { get; set; } + public int Position { get; set; } - public DateTime AddedAt { get; set; } = DateTime.UtcNow; + public DateTime AddedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(PlayListId))] - public PlayList PlayList { get; set; } = null!; + [ForeignKey(nameof(PlayListId))] + public PlayList PlayList { get; set; } = null!; - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } = null!; + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } = null!; + } } diff --git a/backend/Core/Musify.Domain/Entities/Track.cs b/backend/Core/Musify.Domain/Entities/Track.cs index 04da190..e2a7c85 100644 --- a/backend/Core/Musify.Domain/Entities/Track.cs +++ b/backend/Core/Musify.Domain/Entities/Track.cs @@ -3,44 +3,45 @@ using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities; - -[Table("Tracks")] -public class Track : IAuditable, IHasLifeCycle, IOwnedEntity +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("Tracks")] + public class Track : IAuditable, IHasLifeCycle, IOwnedEntity + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [MaxLength(200)] - public required string Title { get; set; } + [MaxLength(200)] + public required string Title { get; set; } - [MaxLength(200)] - public required string NormalizedTitle { get; set; } + [MaxLength(200)] + public required string NormalizedTitle { get; set; } - public double DurationSeconds { get; set; } + public double DurationSeconds { get; set; } - public required long OwnerUserId { get; set; } + public required long OwnerUserId { get; set; } - [ForeignKey(nameof(OwnerUserId))] - public User Owner { get; set; } = null!; + [ForeignKey(nameof(OwnerUserId))] + public User Owner { get; set; } = null!; - public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; + public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; - public bool IsExplicit { get; set; } + public bool IsExplicit { get; set; } - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - public TrackPictures Pictures { get; set; } = new(); + public TrackPictures Pictures { get; set; } = new(); - public TrackAudio Audio { get; set; } = new(); + public TrackAudio Audio { get; set; } = new(); - public ICollection PlayListTracks { get; set; } = new List(); + public ICollection PlayListTracks { get; set; } = new List(); - public ICollection AlbumTracks { get; set; } = new List(); + public ICollection AlbumTracks { get; set; } = new List(); - public ICollection ListeningHistories { get; set; } = new List(); + public ICollection ListeningHistories { get; set; } = new List(); - public ICollection Tags { get; set; } = new List(); + public ICollection Tags { get; set; } = new List(); + } } diff --git a/backend/Core/Musify.Domain/Entities/TrackLike.cs b/backend/Core/Musify.Domain/Entities/TrackLike.cs index 9cf41d3..2e10ce4 100644 --- a/backend/Core/Musify.Domain/Entities/TrackLike.cs +++ b/backend/Core/Musify.Domain/Entities/TrackLike.cs @@ -1,23 +1,24 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities; - -[Table("TrackLikes")] -public class TrackLike +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("TrackLikes")] + public class TrackLike + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - public required long UserId { get; set; } + public required long UserId { get; set; } - public required Guid TrackId { get; set; } + public required Guid TrackId { get; set; } - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(UserId))] - public User User { get; set; } = null!; + [ForeignKey(nameof(UserId))] + public User User { get; set; } = null!; - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } = null!; + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } = null!; + } } diff --git a/backend/Core/Musify.Domain/Entities/TrackTag.cs b/backend/Core/Musify.Domain/Entities/TrackTag.cs index 0f599a4..4bf2389 100644 --- a/backend/Core/Musify.Domain/Entities/TrackTag.cs +++ b/backend/Core/Musify.Domain/Entities/TrackTag.cs @@ -2,18 +2,19 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities; - -[Table("TrackTags")] -public class TrackTag +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("TrackTags")] + public class TrackTag + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - public required Guid TrackId { get; set; } + public required Guid TrackId { get; set; } - public required Genre Tag { get; set; } + public required Genre Tag { get; set; } - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } = null!; + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } = null!; + } } diff --git a/backend/Core/Musify.Domain/Entities/UploadIntent.cs b/backend/Core/Musify.Domain/Entities/UploadIntent.cs index f3fc802..176f8f4 100644 --- a/backend/Core/Musify.Domain/Entities/UploadIntent.cs +++ b/backend/Core/Musify.Domain/Entities/UploadIntent.cs @@ -3,43 +3,44 @@ using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities; - -[Table("UploadIntents")] -public class UploadIntent +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("UploadIntents")] + public class UploadIntent + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - public required long UserId { get; set; } + public required long UserId { get; set; } - [MaxLength(128)] - public required string Bucket { get; set; } + [MaxLength(128)] + public required string Bucket { get; set; } - [MaxLength(512)] - public required string Key { get; set; } + [MaxLength(512)] + public required string Key { get; set; } - [MaxLength(128)] - public required string ObjectName { get; set; } + [MaxLength(128)] + public required string ObjectName { get; set; } - [MaxLength(128)] - public required string ContentType { get; set; } + [MaxLength(128)] + public required string ContentType { get; set; } - public long? ExpectedSizeBytes { get; set; } + public long? ExpectedSizeBytes { get; set; } - public UploadIntentPurpose Purpose { get; set; } + public UploadIntentPurpose Purpose { get; set; } - public UploadIntentStatus Status { get; set; } = UploadIntentStatus.Issued; + public UploadIntentStatus Status { get; set; } = UploadIntentStatus.Issued; - public DateTime ExpiresAt { get; set; } + public DateTime ExpiresAt { get; set; } - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [NotMapped] - public bool IsConsumed => Status == UploadIntentStatus.Consumed; + [NotMapped] + public bool IsConsumed => Status == UploadIntentStatus.Consumed; - public bool IsExpiredAt(DateTime now) => Status == UploadIntentStatus.Expired || ExpiresAt < now; + public bool IsExpiredAt(DateTime now) => Status == UploadIntentStatus.Expired || ExpiresAt < now; - [ForeignKey(nameof(UserId))] - public User User { get; set; } = null!; + [ForeignKey(nameof(UserId))] + public User User { get; set; } = null!; + } } diff --git a/backend/Core/Musify.Domain/Entities/User.cs b/backend/Core/Musify.Domain/Entities/User.cs index 7616a1e..078c99b 100644 --- a/backend/Core/Musify.Domain/Entities/User.cs +++ b/backend/Core/Musify.Domain/Entities/User.cs @@ -2,35 +2,36 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; -namespace Musify.Domain.Entities; - -[Table("User")] -public class User : IAuditable +namespace Musify.Domain.Entities { - [Key] - [DatabaseGenerated(DatabaseGeneratedOption.None)] - public required long Id { get; set; } + [Table("User")] + public class User : IAuditable + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.None)] + public required long Id { get; set; } - [MaxLength(48)] - public required string Name { get; set; } + [MaxLength(48)] + public required string Name { get; set; } - [MaxLength(48)] - public required string NormalizedName { get; set; } + [MaxLength(48)] + public required string NormalizedName { get; set; } - [MaxLength(48)] - public string? FirstName { get; set; } + [MaxLength(48)] + public string? FirstName { get; set; } - [MaxLength(48)] - public string? SecondName { get; set; } + [MaxLength(48)] + public string? SecondName { get; set; } - [MaxLength(2048)] - public string? ProfilePictureUrl { get; set; } + [MaxLength(2048)] + public string? ProfilePictureUrl { get; set; } - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - public ICollection PlayLists { get; set; } = new List(); + public ICollection PlayLists { get; set; } = new List(); - public ICollection ListeningHistories { get; set; } = new List(); + public ICollection ListeningHistories { get; set; } = new List(); + } } diff --git a/backend/Core/Musify.Domain/Entities/UserFollow.cs b/backend/Core/Musify.Domain/Entities/UserFollow.cs index 93b324c..01a9b77 100644 --- a/backend/Core/Musify.Domain/Entities/UserFollow.cs +++ b/backend/Core/Musify.Domain/Entities/UserFollow.cs @@ -1,23 +1,24 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities; - -[Table("UserFollows")] -public class UserFollow +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("UserFollows")] + public class UserFollow + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - public required long FollowerId { get; set; } + public required long FollowerId { get; set; } - public required long FollowedId { get; set; } + public required long FollowedId { get; set; } - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(FollowerId))] - public User Follower { get; set; } = null!; + [ForeignKey(nameof(FollowerId))] + public User Follower { get; set; } = null!; - [ForeignKey(nameof(FollowedId))] - public User Followed { get; set; } = null!; + [ForeignKey(nameof(FollowedId))] + public User Followed { get; set; } = null!; + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/EntityPictures.cs b/backend/Core/Musify.Domain/ValueObjects/EntityPictures.cs index 9cc8db5..bc6e091 100644 --- a/backend/Core/Musify.Domain/ValueObjects/EntityPictures.cs +++ b/backend/Core/Musify.Domain/ValueObjects/EntityPictures.cs @@ -1,18 +1,19 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Domain.ValueObjects; - -/// The picture file names of an album or playlist. Resized names stay null until processing finishes. -public class EntityPictures +namespace Musify.Domain.ValueObjects { - [Required] - public required string OriginalName { get; set; } + /// The picture file names of an album or playlist. Resized names stay null until processing finishes. + public class EntityPictures + { + [Required] + public required string OriginalName { get; set; } - public string? SmallName { get; set; } + public string? SmallName { get; set; } - public string? MediumName { get; set; } + public string? MediumName { get; set; } - public string? LargeName { get; set; } + public string? LargeName { get; set; } - public static EntityPictures Pending(string objectName) => new() { OriginalName = objectName }; + public static EntityPictures Pending(string objectName) => new() { OriginalName = objectName }; + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/Genre.cs b/backend/Core/Musify.Domain/ValueObjects/Genre.cs index d3db9f1..dc9eeb9 100644 --- a/backend/Core/Musify.Domain/ValueObjects/Genre.cs +++ b/backend/Core/Musify.Domain/ValueObjects/Genre.cs @@ -1,31 +1,32 @@ -namespace Musify.Domain.ValueObjects; - -public enum Genre +namespace Musify.Domain.ValueObjects { - Pop = 0, - Rock = 1, - HipHop = 2, - RnB = 3, - Jazz = 4, - Blues = 5, - Classical = 6, - Electronic = 7, - House = 8, - Techno = 9, - Trance = 10, - Dubstep = 11, - DrumAndBass = 12, - Metal = 13, - Punk = 14, - Reggae = 15, - Reggaeton = 16, - Country = 17, - Folk = 18, - Indie = 19, - KPop = 20, - Latin = 21, - Soul = 22, - Funk = 23, - Ambient = 24, - Lofi = 25 + public enum Genre + { + Pop = 0, + Rock = 1, + HipHop = 2, + RnB = 3, + Jazz = 4, + Blues = 5, + Classical = 6, + Electronic = 7, + House = 8, + Techno = 9, + Trance = 10, + Dubstep = 11, + DrumAndBass = 12, + Metal = 13, + Punk = 14, + Reggae = 15, + Reggaeton = 16, + Country = 17, + Folk = 18, + Indie = 19, + KPop = 20, + Latin = 21, + Soul = 22, + Funk = 23, + Ambient = 24, + Lofi = 25 + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs b/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs index 4cd1733..bd753f3 100644 --- a/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs +++ b/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs @@ -1,49 +1,50 @@ -namespace Musify.Domain.ValueObjects; - - -public static class GenreCompatibility +namespace Musify.Domain.ValueObjects { - private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs(); - private static HashSet<(Genre, Genre)> BuildIncompatiblePairs() + public static class GenreCompatibility { - var pairs = new HashSet<(Genre, Genre)>(); + private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs(); - void Add(Genre a, Genre b) + private static HashSet<(Genre, Genre)> BuildIncompatiblePairs() { - pairs.Add((a, b)); - pairs.Add((b, a)); - } + var pairs = new HashSet<(Genre, Genre)>(); - var intense = new[] { Genre.Metal, Genre.Punk, Genre.Dubstep, Genre.DrumAndBass }; - var mellow = new[] { Genre.Classical, Genre.Ambient, Genre.Lofi }; - - foreach (var intenseGenre in intense) - { - foreach (var mellowGenre in mellow) - Add(intenseGenre, mellowGenre); - } - - return pairs; - } - - public static bool AreCompatible(Genre a, Genre b) => - a == b || !IncompatiblePairs.Contains((a, b)); - - public static IReadOnlyCollection<(Genre First, Genre Second)> FindConflicts(IEnumerable tags) - { - var distinctTags = tags.Distinct().ToList(); - var conflicts = new List<(Genre, Genre)>(); - - for (var i = 0; i < distinctTags.Count; i++) - { - for (var j = i + 1; j < distinctTags.Count; j++) + void Add(Genre a, Genre b) { - if (!AreCompatible(distinctTags[i], distinctTags[j])) - conflicts.Add((distinctTags[i], distinctTags[j])); + pairs.Add((a, b)); + pairs.Add((b, a)); } + + var intense = new[] { Genre.Metal, Genre.Punk, Genre.Dubstep, Genre.DrumAndBass }; + var mellow = new[] { Genre.Classical, Genre.Ambient, Genre.Lofi }; + + foreach (var intenseGenre in intense) + { + foreach (var mellowGenre in mellow) + Add(intenseGenre, mellowGenre); + } + + return pairs; } - return conflicts; + public static bool AreCompatible(Genre a, Genre b) => + a == b || !IncompatiblePairs.Contains((a, b)); + + public static IReadOnlyCollection<(Genre First, Genre Second)> FindConflicts(IEnumerable tags) + { + var distinctTags = tags.Distinct().ToList(); + var conflicts = new List<(Genre, Genre)>(); + + for (var i = 0; i < distinctTags.Count; i++) + { + for (var j = i + 1; j < distinctTags.Count; j++) + { + if (!AreCompatible(distinctTags[i], distinctTags[j])) + conflicts.Add((distinctTags[i], distinctTags[j])); + } + } + + return conflicts; + } } } diff --git a/backend/Core/Musify.Domain/ValueObjects/LifeCycleStatus.cs b/backend/Core/Musify.Domain/ValueObjects/LifeCycleStatus.cs index 04f9e20..4e21417 100644 --- a/backend/Core/Musify.Domain/ValueObjects/LifeCycleStatus.cs +++ b/backend/Core/Musify.Domain/ValueObjects/LifeCycleStatus.cs @@ -1,8 +1,9 @@ -namespace Musify.Domain.ValueObjects; - -public enum LifeCycleStatus +namespace Musify.Domain.ValueObjects { - Active = 0, - Removing = 1, - Failed = 2 + public enum LifeCycleStatus + { + Active = 0, + Removing = 1, + Failed = 2 + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/MixKind.cs b/backend/Core/Musify.Domain/ValueObjects/MixKind.cs index 2e3fb69..3a0576f 100644 --- a/backend/Core/Musify.Domain/ValueObjects/MixKind.cs +++ b/backend/Core/Musify.Domain/ValueObjects/MixKind.cs @@ -1,7 +1,8 @@ -namespace Musify.Domain.ValueObjects; - -public enum MixKind +namespace Musify.Domain.ValueObjects { - Discovery = 0, - Daily = 1 + public enum MixKind + { + Discovery = 0, + Daily = 1 + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs b/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs index b6574d8..8ea1473 100644 --- a/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs +++ b/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs @@ -1,7 +1,8 @@ -namespace Musify.Domain.ValueObjects; - -public enum PlayListVisibility +namespace Musify.Domain.ValueObjects { - Private = 0, - Public = 1 + public enum PlayListVisibility + { + Private = 0, + Public = 1 + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs b/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs index d2f681e..7a9fdc2 100644 --- a/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs +++ b/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs @@ -1,9 +1,10 @@ -namespace Musify.Domain.ValueObjects; - -public enum ProcessingStatus +namespace Musify.Domain.ValueObjects { - Pending = 0, - Processing = 1, - Completed = 2, - Failed = 3 + public enum ProcessingStatus + { + Pending = 0, + Processing = 1, + Completed = 2, + Failed = 3 + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs b/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs index 8ed8584..c61ae42 100644 --- a/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs +++ b/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs @@ -1,19 +1,20 @@ using System.Diagnostics.CodeAnalysis; -namespace Musify.Domain.ValueObjects; - -public class TrackAudio +namespace Musify.Domain.ValueObjects { - public string? OriginalName { get; set; } + public class TrackAudio + { + public string? OriginalName { get; set; } - public string? FolderName { get; set; } + public string? FolderName { get; set; } - public ProcessingStatus TranscodeStatus { get; set; } = ProcessingStatus.Pending; + public ProcessingStatus TranscodeStatus { get; set; } = ProcessingStatus.Pending; - [MemberNotNullWhen(true, nameof(FolderName))] - public bool IsProcessed => TranscodeStatus == ProcessingStatus.Completed; + [MemberNotNullWhen(true, nameof(FolderName))] + public bool IsProcessed => TranscodeStatus == ProcessingStatus.Completed; - public bool IsFailed => TranscodeStatus == ProcessingStatus.Failed; + public bool IsFailed => TranscodeStatus == ProcessingStatus.Failed; - public bool IsInProgress => TranscodeStatus is ProcessingStatus.Pending or ProcessingStatus.Processing; + public bool IsInProgress => TranscodeStatus is ProcessingStatus.Pending or ProcessingStatus.Processing; + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs b/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs index 05a57d8..9cf5243 100644 --- a/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs +++ b/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs @@ -1,21 +1,22 @@ using System.Diagnostics.CodeAnalysis; -namespace Musify.Domain.ValueObjects; - -public class TrackPictures +namespace Musify.Domain.ValueObjects { - public string? OriginalName { get; set; } + public class TrackPictures + { + public string? OriginalName { get; set; } - public string? SmallName { get; set; } + public string? SmallName { get; set; } - public string? MediumName { get; set; } + public string? MediumName { get; set; } - public string? LargeName { get; set; } + public string? LargeName { get; set; } - public ProcessingStatus ProcessingStatus { get; set; } = ProcessingStatus.Pending; + public ProcessingStatus ProcessingStatus { get; set; } = ProcessingStatus.Pending; - [MemberNotNullWhen(true, nameof(SmallName))] - [MemberNotNullWhen(true, nameof(MediumName))] - [MemberNotNullWhen(true, nameof(LargeName))] - public bool IsProcessed => ProcessingStatus == ProcessingStatus.Completed; + [MemberNotNullWhen(true, nameof(SmallName))] + [MemberNotNullWhen(true, nameof(MediumName))] + [MemberNotNullWhen(true, nameof(LargeName))] + public bool IsProcessed => ProcessingStatus == ProcessingStatus.Completed; + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs b/backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs index 9d98403..9629594 100644 --- a/backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs +++ b/backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs @@ -1,9 +1,10 @@ -namespace Musify.Domain.ValueObjects; - -public enum UploadIntentPurpose +namespace Musify.Domain.ValueObjects { - PlayListPicture, - TrackPicture, - TrackAudio, - AlbumPicture, + public enum UploadIntentPurpose + { + PlayListPicture, + TrackPicture, + TrackAudio, + AlbumPicture, + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/UploadIntentStatus.cs b/backend/Core/Musify.Domain/ValueObjects/UploadIntentStatus.cs index 85f3934..7a6e23f 100644 --- a/backend/Core/Musify.Domain/ValueObjects/UploadIntentStatus.cs +++ b/backend/Core/Musify.Domain/ValueObjects/UploadIntentStatus.cs @@ -1,8 +1,9 @@ -namespace Musify.Domain.ValueObjects; - -public enum UploadIntentStatus +namespace Musify.Domain.ValueObjects { - Issued, - Consumed, - Expired, + public enum UploadIntentStatus + { + Issued, + Consumed, + Expired, + } } diff --git a/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs index e2abd10..87af825 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs @@ -1,41 +1,42 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Configuration; -namespace Musify.Infrastructure.Configuration; - -public class AudioConfiguration : IConfigurationOptions +namespace Musify.Infrastructure.Configuration { - public static string SectionName => "AudioTranscoder"; + public class AudioConfiguration : IConfigurationOptions + { + public static string SectionName => "AudioTranscoder"; - [Required] - public TimeSpan TranscodingTimeout { get; set; } = TimeSpan.FromMinutes(5); + [Required] + public TimeSpan TranscodingTimeout { get; set; } = TimeSpan.FromMinutes(5); - [Required] - public FfmpegConfiguration Ffmpeg { get; set; } = new FfmpegConfiguration(); -} + [Required] + public FfmpegConfiguration Ffmpeg { get; set; } = new FfmpegConfiguration(); + } -public class FfmpegConfiguration -{ - [Required] - public string ExecutableName { get; set; } = "ffmpeg"; + public class FfmpegConfiguration + { + [Required] + public string ExecutableName { get; set; } = "ffmpeg"; - [Required] - public string OutputFileName { get; set; } = "audio.m4a"; + [Required] + public string OutputFileName { get; set; } = "audio.m4a"; - [Required] - public string AudioCodec { get; set; } = "aac"; + [Required] + public string AudioCodec { get; set; } = "aac"; - [Required] - public string AudioBitrate { get; set; } = "128k"; + [Required] + public string AudioBitrate { get; set; } = "128k"; - [Range(1, 8)] - public int AudioChannels { get; set; } = 2; + [Range(1, 8)] + public int AudioChannels { get; set; } = 2; - [Range(8000, 192000)] - public int AudioSampleRate { get; set; } = 48000; + [Range(8000, 192000)] + public int AudioSampleRate { get; set; } = 48000; - [Required] - public string AudioProfile { get; set; } = "aac_low"; + [Required] + public string AudioProfile { get; set; } = "aac_low"; - public string AdditionalArguments { get; set; } = string.Empty; + public string AdditionalArguments { get; set; } = string.Empty; + } } diff --git a/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs index 6df8c20..d0549ff 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs @@ -1,12 +1,13 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Configuration; -namespace Musify.Infrastructure.Configuration; - -public class DatabaseConfiguration : IConfigurationOptions +namespace Musify.Infrastructure.Configuration { - public static string SectionName => "Database"; + public class DatabaseConfiguration : IConfigurationOptions + { + public static string SectionName => "Database"; - [Required] - public required string ConnectionString { get; set; } + [Required] + public required string ConnectionString { get; set; } + } } diff --git a/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs index 45738bf..6f7a6d7 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs @@ -1,25 +1,26 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Configuration; -namespace Musify.Infrastructure.Configuration; - -public class InfrastructureStorageConfiguration : IConfigurationOptions +namespace Musify.Infrastructure.Configuration { - public static string SectionName => "InfrastructureStorage"; + public class InfrastructureStorageConfiguration : IConfigurationOptions + { + public static string SectionName => "InfrastructureStorage"; - [Url] - [Required] - public required string Address { get; set; } + [Url] + [Required] + public required string Address { get; set; } - [Required] - public required string AccessKey { get; set; } + [Required] + public required string AccessKey { get; set; } - [Required] - public required string SecretAccessKey { get; set; } + [Required] + public required string SecretAccessKey { get; set; } - [Required] - public bool ForcePathStyle { get; set; } + [Required] + public bool ForcePathStyle { get; set; } - [Required] - public bool UseHttp { get; set; } + [Required] + public bool UseHttp { get; set; } + } } diff --git a/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs index 8472cbd..d64a470 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs @@ -1,18 +1,19 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Configuration; -namespace Musify.Infrastructure.Configuration; - -public class MassTransitConfiguration : IConfigurationOptions +namespace Musify.Infrastructure.Configuration { - public static string SectionName => "MassTransit"; + public class MassTransitConfiguration : IConfigurationOptions + { + public static string SectionName => "MassTransit"; - [Required] - public required string Address { get; set; } + [Required] + public required string Address { get; set; } - [Required] - public required string Username { get; set; } + [Required] + public required string Username { get; set; } - [Required] - public required string Password { get; set; } + [Required] + public required string Password { get; set; } + } } diff --git a/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs index e12adcd..3f8e02e 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs @@ -1,13 +1,14 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Configuration; -namespace Musify.Infrastructure.Configuration; - -public class OpenTelemetryConfiguration : IConfigurationOptions +namespace Musify.Infrastructure.Configuration { - public static string SectionName => "OpenTelemetry"; + public class OpenTelemetryConfiguration : IConfigurationOptions + { + public static string SectionName => "OpenTelemetry"; - [Url] - [Required] - public required string OtlpEndpoint { get; set; } + [Url] + [Required] + public required string OtlpEndpoint { get; set; } + } } diff --git a/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs index 26c9130..18120a8 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs @@ -1,21 +1,22 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Configuration; -namespace Musify.Infrastructure.Configuration; - -public class StreamTicketConfiguration : IConfigurationOptions +namespace Musify.Infrastructure.Configuration { - public static string SectionName => "StreamTicket"; + public class StreamTicketConfiguration : IConfigurationOptions + { + public static string SectionName => "StreamTicket"; - [Required] - public required string PrivateKeyPath { get; set; } + [Required] + public required string PrivateKeyPath { get; set; } - [Required] - public string Audience { get; set; } = "media-gateway"; + [Required] + public string Audience { get; set; } = "media-gateway"; - [Required] - public string Issuer { get; set; } = "musify-webapi"; + [Required] + public string Issuer { get; set; } = "musify-webapi"; - [Range(60, 86400)] - public int TicketTtlSeconds { get; set; } = 3600; + [Range(60, 86400)] + public int TicketTtlSeconds { get; set; } = 3600; + } } diff --git a/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs index 3af6ef4..d7309b9 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs @@ -1,18 +1,19 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Configuration; -namespace Musify.Infrastructure.Configuration; - -public class WorkerConfiguration : IConfigurationOptions +namespace Musify.Infrastructure.Configuration { - public static string SectionName => "Workers"; + public class WorkerConfiguration : IConfigurationOptions + { + public static string SectionName => "Workers"; - [Required] - public WorkerRoutes Routes { get; set; } = new WorkerRoutes(); -} + [Required] + public WorkerRoutes Routes { get; set; } = new WorkerRoutes(); + } -public class WorkerRoutes -{ - [Required] - public string TemporaryFilesDirectory { get; set; } = Path.GetTempPath(); + public class WorkerRoutes + { + [Required] + public string TemporaryFilesDirectory { get; set; } = Path.GetTempPath(); + } } diff --git a/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs index d610c1e..5ff17e7 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs @@ -4,27 +4,28 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Mixes; -namespace Musify.Infrastructure.Jobs; - -public class DailyMixGenerationJob( - IDatabase database, - IMediator mediator, - ILogger logger) +namespace Musify.Infrastructure.Jobs { - public async Task RunAsync(CancellationToken cancellationToken) + public class DailyMixGenerationJob( + IDatabase database, + IMediator mediator, + ILogger logger) { - var userIds = await database.ListeningHistories - .AsNoTracking() - .Where(history => history.IsCounted) - .Select(history => history.UserId) - .Distinct() - .ToListAsync(cancellationToken); + public async Task RunAsync(CancellationToken cancellationToken) + { + var userIds = await database.ListeningHistories + .AsNoTracking() + .Where(history => history.IsCounted) + .Select(history => history.UserId) + .Distinct() + .ToListAsync(cancellationToken); - logger.LogInformation("Starting daily mix generation for {Count} users", userIds.Count); + logger.LogInformation("Starting daily mix generation for {Count} users", userIds.Count); - foreach (var userId in userIds) - await mediator.Send(new GenerateMixesForUserCommand(userId), cancellationToken); + foreach (var userId in userIds) + await mediator.Send(new GenerateMixesForUserCommand(userId), cancellationToken); - logger.LogInformation("Finished daily mix generation"); + logger.LogInformation("Finished daily mix generation"); + } } } diff --git a/backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs b/backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs index 9a91b3c..fc8e1b3 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs @@ -1,12 +1,13 @@ using Mediator; using Musify.Application.Tracks; -namespace Musify.Infrastructure.Jobs; - -public class ListeningHistoryCleanupJob(IMediator mediator) +namespace Musify.Infrastructure.Jobs { - public async Task RunAsync(CancellationToken cancellationToken) + public class ListeningHistoryCleanupJob(IMediator mediator) { - await mediator.Send(new DeleteStaleUncountedListensCommand(), cancellationToken); + public async Task RunAsync(CancellationToken cancellationToken) + { + await mediator.Send(new DeleteStaleUncountedListensCommand(), cancellationToken); + } } } diff --git a/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs b/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs index ed278f6..a2f154b 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs @@ -2,40 +2,41 @@ using Hangfire; using Microsoft.Extensions.Hosting; using Musify.Application.Configuration; -namespace Musify.Infrastructure.Jobs; - -/// Registers every recurring Hangfire job when the host starts. -public sealed class RecurringJobsRegistrar( - IRecurringJobManager recurringJobManager, - UploadIntentConfiguration uploadIntentConfiguration) - : IHostedService +namespace Musify.Infrastructure.Jobs { - public Task StartAsync(CancellationToken cancellationToken) + /// Registers every recurring Hangfire job when the host starts. + public sealed class RecurringJobsRegistrar( + IRecurringJobManager recurringJobManager, + UploadIntentConfiguration uploadIntentConfiguration) + : IHostedService { - var options = new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }; + public Task StartAsync(CancellationToken cancellationToken) + { + var options = new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }; - recurringJobManager.AddOrUpdate( - "daily-mix-generation", job => job.RunAsync(CancellationToken.None), Cron.Daily(), options); - recurringJobManager.AddOrUpdate( - "listening-history-cleanup", job => job.RunAsync(CancellationToken.None), Cron.Daily(3), options); - recurringJobManager.AddOrUpdate( - "upload-intent-expiration", job => job.RunAsync(CancellationToken.None), - EveryCron(uploadIntentConfiguration.ExpirationJobIntervalSeconds), options); - recurringJobManager.AddOrUpdate( - "temp-uploads-cleanup", job => job.RunAsync(CancellationToken.None), - EveryCron(uploadIntentConfiguration.TempCleanupJobIntervalSeconds), options); + recurringJobManager.AddOrUpdate( + "daily-mix-generation", job => job.RunAsync(CancellationToken.None), Cron.Daily(), options); + recurringJobManager.AddOrUpdate( + "listening-history-cleanup", job => job.RunAsync(CancellationToken.None), Cron.Daily(3), options); + recurringJobManager.AddOrUpdate( + "upload-intent-expiration", job => job.RunAsync(CancellationToken.None), + EveryCron(uploadIntentConfiguration.ExpirationJobIntervalSeconds), options); + recurringJobManager.AddOrUpdate( + "temp-uploads-cleanup", job => job.RunAsync(CancellationToken.None), + EveryCron(uploadIntentConfiguration.TempCleanupJobIntervalSeconds), options); - return Task.CompletedTask; - } + return Task.CompletedTask; + } - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - /// Hangfire cron has a one-minute resolution, so the interval is rounded up to whole minutes. - internal static string EveryCron(int intervalSeconds) - { - var minutes = Math.Max(1, (int)Math.Ceiling(intervalSeconds / 60.0)); - return minutes < 60 - ? $"*/{minutes} * * * *" - : $"0 */{Math.Max(1, minutes / 60)} * * *"; + /// Hangfire cron has a one-minute resolution, so the interval is rounded up to whole minutes. + internal static string EveryCron(int intervalSeconds) + { + var minutes = Math.Max(1, (int)Math.Ceiling(intervalSeconds / 60.0)); + return minutes < 60 + ? $"*/{minutes} * * * *" + : $"0 */{Math.Max(1, minutes / 60)} * * *"; + } } } diff --git a/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs b/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs index 5295904..a8aecff 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs @@ -1,10 +1,11 @@ using Mediator; using Musify.Application.UploadIntents; -namespace Musify.Infrastructure.Jobs; - -public class TempUploadsCleanupJob(IMediator mediator) +namespace Musify.Infrastructure.Jobs { - public async Task RunAsync(CancellationToken cancellationToken) => - await mediator.Send(new DeleteStaleTempUploadsCommand(), cancellationToken); + public class TempUploadsCleanupJob(IMediator mediator) + { + public async Task RunAsync(CancellationToken cancellationToken) => + await mediator.Send(new DeleteStaleTempUploadsCommand(), cancellationToken); + } } diff --git a/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs b/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs index 3a6d37d..66c46c1 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs @@ -1,10 +1,11 @@ using Mediator; using Musify.Application.UploadIntents; -namespace Musify.Infrastructure.Jobs; - -public class UploadIntentExpirationJob(IMediator mediator) +namespace Musify.Infrastructure.Jobs { - public async Task RunAsync(CancellationToken cancellationToken) => - await mediator.Send(new ExpireUploadIntentsCommand(), cancellationToken); + public class UploadIntentExpirationJob(IMediator mediator) + { + public async Task RunAsync(CancellationToken cancellationToken) => + await mediator.Send(new ExpireUploadIntentsCommand(), cancellationToken); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs index ae54c09..9bae866 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs @@ -3,29 +3,30 @@ using Microsoft.Extensions.Logging; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.Albums; - -internal class PublishAlbumPictureProcessingEventActivity( - IPublishEndpoint publishEndpoint, - ILogger logger) - : IExecuteActivity +namespace Musify.Infrastructure.MassTransit.Activities.Albums { - public const string ExecuteEndpointName = "publish-album-picture-processing-event"; - - public async Task Execute(ExecuteContext executeContext) + internal class PublishAlbumPictureProcessingEventActivity( + IPublishEndpoint publishEndpoint, + ILogger logger) + : IExecuteActivity { - var arguments = executeContext.Arguments; + public const string ExecuteEndpointName = "publish-album-picture-processing-event"; - await publishEndpoint.Publish( - new UpdateAlbumPictureEvent( - arguments.AlbumId, - arguments.Bucket, - arguments.FinalPictureKey, - arguments.Sizes), - executeContext.CancellationToken); + public async Task Execute(ExecuteContext executeContext) + { + var arguments = executeContext.Arguments; - logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId); + await publishEndpoint.Publish( + new UpdateAlbumPictureEvent( + arguments.AlbumId, + arguments.Bucket, + arguments.FinalPictureKey, + arguments.Sizes), + executeContext.CancellationToken); - return executeContext.Completed(); + logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId); + + return executeContext.Completed(); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs index 9b38fb0..f0b102b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs @@ -5,43 +5,44 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Activities.Audio; - -internal class GenerateAudioWorkflowPathsActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity +namespace Musify.Infrastructure.MassTransit.Activities.Audio { - public const string ExecuteEndpointName = "generate-audio-workflow-paths"; - - public async Task Execute(ExecuteContext executeContext) + internal class GenerateAudioWorkflowPathsActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity { - var arguments = executeContext.Arguments; - var track = await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); + public const string ExecuteEndpointName = "generate-audio-workflow-paths"; - try + public async Task Execute(ExecuteContext executeContext) { - track.Audio.TranscodeStatus = ProcessingStatus.Processing; + var arguments = executeContext.Arguments; + var track = await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); - var workingDirectory = Path.Combine(arguments.TemporaryRootDirectory, Guid.NewGuid().ToString()); - var sourceFilePath = Path.Combine(workingDirectory, Path.GetFileName(arguments.SourceKey)); - - Directory.CreateDirectory(workingDirectory); - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogDebug("Generated audio workflow paths in {WorkingDirectory}", workingDirectory); - - return executeContext.CompletedWithVariables(new Dictionary + try { - [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, - [RoutingSlipVariableNames.Audio.SourceFilePath] = sourceFilePath, - [RoutingSlipVariableNames.Audio.TranscodedDirectory] = workingDirectory - }); - } - catch - { - await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); - throw; + track.Audio.TranscodeStatus = ProcessingStatus.Processing; + + var workingDirectory = Path.Combine(arguments.TemporaryRootDirectory, Guid.NewGuid().ToString()); + var sourceFilePath = Path.Combine(workingDirectory, Path.GetFileName(arguments.SourceKey)); + + Directory.CreateDirectory(workingDirectory); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogDebug("Generated audio workflow paths in {WorkingDirectory}", workingDirectory); + + return executeContext.CompletedWithVariables(new Dictionary + { + [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, + [RoutingSlipVariableNames.Audio.SourceFilePath] = sourceFilePath, + [RoutingSlipVariableNames.Audio.TranscodedDirectory] = workingDirectory + }); + } + catch + { + await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); + throw; + } } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TrackAudioStatus.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TrackAudioStatus.cs index 7f40abc..ba69169 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TrackAudioStatus.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TrackAudioStatus.cs @@ -3,22 +3,23 @@ using Musify.Application.Contracts; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Infrastructure.MassTransit.Activities.Audio; - -/// Shared bookkeeping of the audio processing activities. -internal static class TrackAudioStatus +namespace Musify.Infrastructure.MassTransit.Activities.Audio { - public static async Task LoadAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) => - await database.Tracks.SingleOrDefaultAsync(track => track.Id == trackId, cancellationToken) - ?? throw new InvalidOperationException($"Track with id {trackId} not found"); - - public static async Task MarkFailedAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) + /// Shared bookkeeping of the audio processing activities. + internal static class TrackAudioStatus { - var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); - if (track == null) - return; + public static async Task LoadAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) => + await database.Tracks.SingleOrDefaultAsync(track => track.Id == trackId, cancellationToken) + ?? throw new InvalidOperationException($"Track with id {trackId} not found"); - track.Audio.TranscodeStatus = ProcessingStatus.Failed; - await database.SaveChangesAsync(cancellationToken); + public static async Task MarkFailedAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) + { + var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); + if (track == null) + return; + + track.Audio.TranscodeStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(cancellationToken); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs index ee7e5d1..1321e01 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs @@ -5,61 +5,62 @@ using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Activities.Audio; - -internal class TranscodeAudioActivity( - IDatabase database, - IAudioTranscoderService audioTranscoder, - ILogger logger) - : IActivity +namespace Musify.Infrastructure.MassTransit.Activities.Audio { - public const string ExecuteEndpointName = "transcode-audio"; - - public async Task Execute(ExecuteContext executeContext) + internal class TranscodeAudioActivity( + IDatabase database, + IAudioTranscoderService audioTranscoder, + ILogger logger) + : IActivity { - var arguments = executeContext.Arguments; - var sourceFilePath = executeContext.GetVariable(arguments.SourceFilePathVariable); - ArgumentNullException.ThrowIfNull(sourceFilePath); - var workingDirectory = executeContext.GetVariable(arguments.WorkingDirectoryVariable); - ArgumentNullException.ThrowIfNull(workingDirectory); + public const string ExecuteEndpointName = "transcode-audio"; - await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); - - try + public async Task Execute(ExecuteContext executeContext) { - AudioTranscodeResult transcodeResult; - await using (var fileStream = File.OpenRead(sourceFilePath)) + var arguments = executeContext.Arguments; + var sourceFilePath = executeContext.GetVariable(arguments.SourceFilePathVariable); + ArgumentNullException.ThrowIfNull(sourceFilePath); + var workingDirectory = executeContext.GetVariable(arguments.WorkingDirectoryVariable); + ArgumentNullException.ThrowIfNull(workingDirectory); + + await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); + + try { - transcodeResult = await audioTranscoder.TranscodeToAudioFileAsync( - fileStream, workingDirectory, executeContext.CancellationToken); + AudioTranscodeResult transcodeResult; + await using (var fileStream = File.OpenRead(sourceFilePath)) + { + transcodeResult = await audioTranscoder.TranscodeToAudioFileAsync( + fileStream, workingDirectory, executeContext.CancellationToken); + } + + if (transcodeResult.StatusCode != 0) + throw new InvalidOperationException($"ffmpeg transcoding failed with exit code {transcodeResult.StatusCode}."); + + File.Delete(sourceFilePath); + + return executeContext.CompletedWithVariables(new TranscodeAudioLog(workingDirectory), new Dictionary + { + [RoutingSlipVariableNames.Audio.DurationSeconds] = transcodeResult.Duration.TotalSeconds + }); } - - if (transcodeResult.StatusCode != 0) - throw new InvalidOperationException($"ffmpeg transcoding failed with exit code {transcodeResult.StatusCode}."); - - File.Delete(sourceFilePath); - - return executeContext.CompletedWithVariables(new TranscodeAudioLog(workingDirectory), new Dictionary + catch { - [RoutingSlipVariableNames.Audio.DurationSeconds] = transcodeResult.Duration.TotalSeconds - }); + await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); + throw; + } } - catch + + public Task Compensate(CompensateContext compensateContext) { - await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); - throw; + var workingDirectory = compensateContext.Log.WorkingDirectory; + + if (Directory.Exists(workingDirectory)) + Directory.Delete(workingDirectory, recursive: true); + else + logger.LogWarning("Working directory {WorkingDirectory} not found during compensation", workingDirectory); + + return Task.FromResult(compensateContext.Compensated()); } } - - public Task Compensate(CompensateContext compensateContext) - { - var workingDirectory = compensateContext.Log.WorkingDirectory; - - if (Directory.Exists(workingDirectory)) - Directory.Delete(workingDirectory, recursive: true); - else - logger.LogWarning("Working directory {WorkingDirectory} not found during compensation", workingDirectory); - - return Task.FromResult(compensateContext.Compensated()); - } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs index d683d09..0771916 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs @@ -6,61 +6,62 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Audio; - -internal class UpdateTrackAudioActivity( - IDatabase database, - IPublishEndpoint publishEndpoint, - ILogger logger) - : IActivity +namespace Musify.Infrastructure.MassTransit.Activities.Audio { - public const string ExecuteEndpointName = "update-track-audio"; - - public async Task Execute(ExecuteContext executeContext) + internal class UpdateTrackAudioActivity( + IDatabase database, + IPublishEndpoint publishEndpoint, + ILogger logger) + : IActivity { - var arguments = executeContext.Arguments; - var track = await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); + public const string ExecuteEndpointName = "update-track-audio"; - try + public async Task Execute(ExecuteContext executeContext) { - if (string.IsNullOrWhiteSpace(arguments.AudioFolderKey)) - throw new InvalidOperationException($"Audio folder key is empty for track {track.Id}"); + var arguments = executeContext.Arguments; + var track = await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); - var durationSeconds = executeContext.GetVariable(arguments.DurationSecondsVariable) - ?? throw new InvalidOperationException($"Missing routing slip variable {arguments.DurationSecondsVariable}"); + try + { + if (string.IsNullOrWhiteSpace(arguments.AudioFolderKey)) + throw new InvalidOperationException($"Audio folder key is empty for track {track.Id}"); - var log = new UpdateTrackAudioLog(track.Id, track.Audio.FolderName, track.DurationSeconds); + var durationSeconds = executeContext.GetVariable(arguments.DurationSecondsVariable) + ?? throw new InvalidOperationException($"Missing routing slip variable {arguments.DurationSecondsVariable}"); - track.Audio.FolderName = Path.GetFileName(arguments.AudioFolderKey); - track.DurationSeconds = durationSeconds; - track.Audio.TranscodeStatus = ProcessingStatus.Completed; - await database.SaveChangesAsync(executeContext.CancellationToken); + var log = new UpdateTrackAudioLog(track.Id, track.Audio.FolderName, track.DurationSeconds); - logger.LogInformation("Updated track {TrackId} audio", arguments.TrackId); + track.Audio.FolderName = Path.GetFileName(arguments.AudioFolderKey); + track.DurationSeconds = durationSeconds; + track.Audio.TranscodeStatus = ProcessingStatus.Completed; + await database.SaveChangesAsync(executeContext.CancellationToken); - await publishEndpoint.Publish(new TrackAudioProcessed(arguments.TrackId), executeContext.CancellationToken); + logger.LogInformation("Updated track {TrackId} audio", arguments.TrackId); - return executeContext.Completed(log); + await publishEndpoint.Publish(new TrackAudioProcessed(arguments.TrackId), executeContext.CancellationToken); + + return executeContext.Completed(log); + } + catch + { + await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); + throw; + } } - catch + + public async Task Compensate(CompensateContext compensateContext) { - await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); - throw; + var log = compensateContext.Log; + + var track = await TrackAudioStatus.LoadAsync(database, log.TrackId, compensateContext.CancellationToken); + track.Audio.FolderName = log.PreviousAudioFolderName; + track.DurationSeconds = log.PreviousDurationSeconds; + track.Audio.TranscodeStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(compensateContext.CancellationToken); + + logger.LogInformation("Compensated track {TrackId} audio", log.TrackId); + + return compensateContext.Compensated(); } } - - public async Task Compensate(CompensateContext compensateContext) - { - var log = compensateContext.Log; - - var track = await TrackAudioStatus.LoadAsync(database, log.TrackId, compensateContext.CancellationToken); - track.Audio.FolderName = log.PreviousAudioFolderName; - track.DurationSeconds = log.PreviousDurationSeconds; - track.Audio.TranscodeStatus = ProcessingStatus.Failed; - await database.SaveChangesAsync(compensateContext.CancellationToken); - - logger.LogInformation("Compensated track {TrackId} audio", log.TrackId); - - return compensateContext.Compensated(); - } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs index 5662355..c7340cd 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs @@ -4,45 +4,46 @@ using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Files; - -internal class CopyFileInBucketActivity( - IStorageService storageService, - ILogger logger) - : IActivity +namespace Musify.Infrastructure.MassTransit.Activities.Files { - public const string ExecuteEndpointName = "copy-file-in-bucket"; - - public async Task Execute(ExecuteContext executeContext) + internal class CopyFileInBucketActivity( + IStorageService storageService, + ILogger logger) + : IActivity { - await storageService.CopyFileAsync( - executeContext.Arguments.SourceBucket, - executeContext.Arguments.SourceKey, - executeContext.Arguments.DestinationBucket, - executeContext.Arguments.DestinationKey, - executeContext.CancellationToken); + public const string ExecuteEndpointName = "copy-file-in-bucket"; - return executeContext.Completed(new CopyFileInBucketLog( - executeContext.Arguments.DestinationBucket, - executeContext.Arguments.DestinationKey)); - } - - public async Task Compensate(CompensateContext compensateContext) - { - try + public async Task Execute(ExecuteContext executeContext) { - await storageService.RemoveFileAsync( - compensateContext.Log.DestinationBucket, - compensateContext.Log.DestinationKey, - compensateContext.CancellationToken); - return compensateContext.Compensated(); + await storageService.CopyFileAsync( + executeContext.Arguments.SourceBucket, + executeContext.Arguments.SourceKey, + executeContext.Arguments.DestinationBucket, + executeContext.Arguments.DestinationKey, + executeContext.CancellationToken); + + return executeContext.Completed(new CopyFileInBucketLog( + executeContext.Arguments.DestinationBucket, + executeContext.Arguments.DestinationKey)); } - catch (Exception exception) + + public async Task Compensate(CompensateContext compensateContext) { - logger.LogError(exception, "Failed to compensate copied file {Bucket}/{Key}", - compensateContext.Log.DestinationBucket, - compensateContext.Log.DestinationKey); - return compensateContext.Failed(exception); + try + { + await storageService.RemoveFileAsync( + compensateContext.Log.DestinationBucket, + compensateContext.Log.DestinationKey, + compensateContext.CancellationToken); + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate copied file {Bucket}/{Key}", + compensateContext.Log.DestinationBucket, + compensateContext.Log.DestinationKey); + return compensateContext.Failed(exception); + } } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs index 3faab94..67934db 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs @@ -4,52 +4,53 @@ using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Files; - -internal class DownloadFileFromBucketActivity( - IStorageService storageService, - ILogger logger) - : IActivity +namespace Musify.Infrastructure.MassTransit.Activities.Files { - public const string ExecuteEndpointName = "download-file-from-bucket"; - - public async Task Execute(ExecuteContext executeContext) + internal class DownloadFileFromBucketActivity( + IStorageService storageService, + ILogger logger) + : IActivity { - var destinationPath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); - ArgumentNullException.ThrowIfNull(destinationPath); + public const string ExecuteEndpointName = "download-file-from-bucket"; - using var fileStream = await storageService.GetFileAsync( - executeContext.Arguments.Bucket, - executeContext.Arguments.Key, - executeContext.CancellationToken) - ?? throw new FileNotFoundException($"Object {executeContext.Arguments.Bucket}/{executeContext.Arguments.Key} was not found."); - - var destinationDirectory = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrWhiteSpace(destinationDirectory)) - Directory.CreateDirectory(destinationDirectory); - - if (fileStream.CanSeek) - fileStream.Position = 0; - - using var destinationStream = File.Create(destinationPath); - await fileStream.CopyToAsync(destinationStream, executeContext.CancellationToken); - return executeContext.Completed(new DownloadFileFromBucketLog( - executeContext.Arguments.Bucket, - executeContext.Arguments.Key, - destinationPath)); - } - - public Task Compensate(CompensateContext compensateContext) - { - try + public async Task Execute(ExecuteContext executeContext) { - File.Delete(compensateContext.Log.DestinationFilePath); - return Task.FromResult(compensateContext.Compensated()); + var destinationPath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); + ArgumentNullException.ThrowIfNull(destinationPath); + + using var fileStream = await storageService.GetFileAsync( + executeContext.Arguments.Bucket, + executeContext.Arguments.Key, + executeContext.CancellationToken) + ?? throw new FileNotFoundException($"Object {executeContext.Arguments.Bucket}/{executeContext.Arguments.Key} was not found."); + + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrWhiteSpace(destinationDirectory)) + Directory.CreateDirectory(destinationDirectory); + + if (fileStream.CanSeek) + fileStream.Position = 0; + + using var destinationStream = File.Create(destinationPath); + await fileStream.CopyToAsync(destinationStream, executeContext.CancellationToken); + return executeContext.Completed(new DownloadFileFromBucketLog( + executeContext.Arguments.Bucket, + executeContext.Arguments.Key, + destinationPath)); } - catch (Exception exception) + + public Task Compensate(CompensateContext compensateContext) { - logger.LogError(exception, "Failed to compensate downloaded file {DestinationFilePath}", compensateContext.Log.DestinationFilePath); - return Task.FromResult(compensateContext.Failed(exception)); + try + { + File.Delete(compensateContext.Log.DestinationFilePath); + return Task.FromResult(compensateContext.Compensated()); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate downloaded file {DestinationFilePath}", compensateContext.Log.DestinationFilePath); + return Task.FromResult(compensateContext.Failed(exception)); + } } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs index e5fce5d..7432997 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs @@ -5,34 +5,35 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.Files; - -internal class RemoveFileFromBucketActivity( - IStorageService storageService, - ILogger logger) - : IExecuteActivity +namespace Musify.Infrastructure.MassTransit.Activities.Files { - public const string ExecuteEndpointName = "remove-file-from-bucket"; - - public async Task Execute(ExecuteContext executeContext) + internal class RemoveFileFromBucketActivity( + IStorageService storageService, + ILogger logger) + : IExecuteActivity { - try + public const string ExecuteEndpointName = "remove-file-from-bucket"; + + public async Task Execute(ExecuteContext executeContext) { - await storageService.RemoveFileAsync( - executeContext.Arguments.Bucket, - executeContext.Arguments.Key, - executeContext.CancellationToken); - return executeContext.Completed(); - } - catch (AmazonS3Exception exception) when ( - exception.StatusCode == HttpStatusCode.NotFound - || string.Equals(exception.ErrorCode, "NoSuchKey", StringComparison.OrdinalIgnoreCase)) - { - logger.LogInformation( - "Key {Key} not found in bucket {Bucket}, skipping", - executeContext.Arguments.Key, - executeContext.Arguments.Bucket); - return executeContext.Completed(); + try + { + await storageService.RemoveFileAsync( + executeContext.Arguments.Bucket, + executeContext.Arguments.Key, + executeContext.CancellationToken); + return executeContext.Completed(); + } + catch (AmazonS3Exception exception) when ( + exception.StatusCode == HttpStatusCode.NotFound + || string.Equals(exception.ErrorCode, "NoSuchKey", StringComparison.OrdinalIgnoreCase)) + { + logger.LogInformation( + "Key {Key} not found in bucket {Bucket}, skipping", + executeContext.Arguments.Key, + executeContext.Arguments.Bucket); + return executeContext.Completed(); + } } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs index 7b5f310..8bffd7c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs @@ -4,51 +4,52 @@ using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Files; - -internal class TransferFilesToBucketActivity( - IStorageService storageService, - ILogger logger) - : IActivity +namespace Musify.Infrastructure.MassTransit.Activities.Files { - public const string ExecuteEndpointName = "transfer-files-to-bucket"; - - public async Task Execute(ExecuteContext executeContext) + internal class TransferFilesToBucketActivity( + IStorageService storageService, + ILogger logger) + : IActivity { - var folderPath = executeContext.GetVariable(executeContext.Arguments.SourceDirectoryVariable); - ArgumentNullException.ThrowIfNull(folderPath); - var destinationKey = executeContext.Arguments.DestinationKey; - ArgumentNullException.ThrowIfNull(destinationKey); + public const string ExecuteEndpointName = "transfer-files-to-bucket"; - var uploadedKeys = await storageService.TransferFilesAsync( - folderPath, - executeContext.Arguments.DestinationBucket, - destinationKey, - executeContext.CancellationToken); - - return executeContext.Completed(new TransferFilesToBucketLog( - executeContext.Arguments.DestinationBucket, - uploadedKeys.ToArray())); - } - - public async Task Compensate(CompensateContext compensateContext) - { - try + public async Task Execute(ExecuteContext executeContext) { - foreach (var uploadedKey in compensateContext.Log.UploadedKeys) - { - await storageService.RemoveFileAsync( - compensateContext.Log.DestinationBucket, - uploadedKey, - compensateContext.CancellationToken); - } + var folderPath = executeContext.GetVariable(executeContext.Arguments.SourceDirectoryVariable); + ArgumentNullException.ThrowIfNull(folderPath); + var destinationKey = executeContext.Arguments.DestinationKey; + ArgumentNullException.ThrowIfNull(destinationKey); - return compensateContext.Compensated(); + var uploadedKeys = await storageService.TransferFilesAsync( + folderPath, + executeContext.Arguments.DestinationBucket, + destinationKey, + executeContext.CancellationToken); + + return executeContext.Completed(new TransferFilesToBucketLog( + executeContext.Arguments.DestinationBucket, + uploadedKeys.ToArray())); } - catch (Exception exception) + + public async Task Compensate(CompensateContext compensateContext) { - logger.LogError(exception, "Failed to compensate file transfers to {DestinationBucket}", compensateContext.Log.DestinationBucket); - return compensateContext.Failed(exception); + try + { + foreach (var uploadedKey in compensateContext.Log.UploadedKeys) + { + await storageService.RemoveFileAsync( + compensateContext.Log.DestinationBucket, + uploadedKey, + compensateContext.CancellationToken); + } + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate file transfers to {DestinationBucket}", compensateContext.Log.DestinationBucket); + return compensateContext.Failed(exception); + } } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs index f66d1db..c580bf3 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs @@ -4,49 +4,50 @@ using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Files; - -internal class UploadFileToBucketActivity(IStorageService storageService, - ILogger logger) - : IActivity +namespace Musify.Infrastructure.MassTransit.Activities.Files { - public const string ExecuteEndpointName = "upload-file-to-bucket"; - - public async Task Execute(ExecuteContext executeContext) + internal class UploadFileToBucketActivity(IStorageService storageService, + ILogger logger) + : IActivity { - var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.FilePathVariable); - ArgumentNullException.ThrowIfNull(sourceFilePath); - var fileName = Path.GetFileName(sourceFilePath); - var destinationKey = string.Join('/', new[] { executeContext.Arguments.DestinationRoute, fileName } - .Where(static s => !string.IsNullOrWhiteSpace(s)) - .Select(static s => s.Trim().Trim('/', '\\'))); + public const string ExecuteEndpointName = "upload-file-to-bucket"; - await storageService.UploadFileAsync( - sourceFilePath, - executeContext.Arguments.DestinationBucket, - destinationKey, - executeContext.CancellationToken); - return executeContext.CompletedWithVariables(new UploadFileToBucketLog( - executeContext.Arguments.DestinationBucket, - destinationKey)); - } - - public async Task Compensate(CompensateContext compensateContext) - { - try + public async Task Execute(ExecuteContext executeContext) { - await storageService.RemoveFileAsync( - compensateContext.Log.DestinationBucket, - compensateContext.Log.DestinationKey, - compensateContext.CancellationToken); - return compensateContext.Compensated(); + var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.FilePathVariable); + ArgumentNullException.ThrowIfNull(sourceFilePath); + var fileName = Path.GetFileName(sourceFilePath); + var destinationKey = string.Join('/', new[] { executeContext.Arguments.DestinationRoute, fileName } + .Where(static s => !string.IsNullOrWhiteSpace(s)) + .Select(static s => s.Trim().Trim('/', '\\'))); + + await storageService.UploadFileAsync( + sourceFilePath, + executeContext.Arguments.DestinationBucket, + destinationKey, + executeContext.CancellationToken); + return executeContext.CompletedWithVariables(new UploadFileToBucketLog( + executeContext.Arguments.DestinationBucket, + destinationKey)); } - catch (Exception exception) + + public async Task Compensate(CompensateContext compensateContext) { - logger.LogError(exception, "Error compensating file upload for {Bucket}/{Key}", - compensateContext.Log.DestinationBucket, - compensateContext.Log.DestinationKey); - return compensateContext.Failed(exception); + try + { + await storageService.RemoveFileAsync( + compensateContext.Log.DestinationBucket, + compensateContext.Log.DestinationKey, + compensateContext.CancellationToken); + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Error compensating file upload for {Bucket}/{Key}", + compensateContext.Log.DestinationBucket, + compensateContext.Log.DestinationKey); + return compensateContext.Failed(exception); + } } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/LifeCycle/LifeCycleActivities.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/LifeCycle/LifeCycleActivities.cs index a69d39e..52bab3e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/LifeCycle/LifeCycleActivities.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/LifeCycle/LifeCycleActivities.cs @@ -6,92 +6,93 @@ using Musify.Domain.Abstractions; using Musify.Domain.Entities; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.LifeCycle; - -/// Sets the life-cycle status of an entity (removing, failed, ...). A missing entity is skipped. -internal abstract class MarkLifeCycleActivity(IDatabase database, ILogger logger) - : IExecuteActivity - where TEntity : class, IHasLifeCycle +namespace Musify.Infrastructure.MassTransit.Activities.LifeCycle { - public async Task Execute(ExecuteContext executeContext) + /// Sets the life-cycle status of an entity (removing, failed, ...). A missing entity is skipped. + internal abstract class MarkLifeCycleActivity(IDatabase database, ILogger logger) + : IExecuteActivity + where TEntity : class, IHasLifeCycle { - var arguments = executeContext.Arguments; - var entity = await database.Set() - .SingleOrDefaultAsync(e => e.Id == arguments.Id, executeContext.CancellationToken); - - if (entity == null) + public async Task Execute(ExecuteContext executeContext) { - logger.LogInformation("{Entity} {Id} not found while marking it as {Status}, skipping", typeof(TEntity).Name, arguments.Id, arguments.Status); + var arguments = executeContext.Arguments; + var entity = await database.Set() + .SingleOrDefaultAsync(e => e.Id == arguments.Id, executeContext.CancellationToken); + + if (entity == null) + { + logger.LogInformation("{Entity} {Id} not found while marking it as {Status}, skipping", typeof(TEntity).Name, arguments.Id, arguments.Status); + return executeContext.Completed(); + } + + entity.LifeCycleStatus = arguments.Status; + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Marked {Entity} {Id} as {Status}", typeof(TEntity).Name, arguments.Id, arguments.Status); return executeContext.Completed(); } - - entity.LifeCycleStatus = arguments.Status; - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Marked {Entity} {Id} as {Status}", typeof(TEntity).Name, arguments.Id, arguments.Status); - return executeContext.Completed(); } -} -/// Deletes an entity row. A missing entity is skipped. -internal abstract class DeleteEntityActivity(IDatabase database, ILogger logger) - : IExecuteActivity - where TEntity : class, IHasLifeCycle -{ - public async Task Execute(ExecuteContext executeContext) + /// Deletes an entity row. A missing entity is skipped. + internal abstract class DeleteEntityActivity(IDatabase database, ILogger logger) + : IExecuteActivity + where TEntity : class, IHasLifeCycle { - var id = executeContext.Arguments.Id; - var entity = await database.Set() - .SingleOrDefaultAsync(e => e.Id == id, executeContext.CancellationToken); - - if (entity == null) + public async Task Execute(ExecuteContext executeContext) { - logger.LogInformation("{Entity} {Id} not found while deleting it, skipping", typeof(TEntity).Name, id); + var id = executeContext.Arguments.Id; + var entity = await database.Set() + .SingleOrDefaultAsync(e => e.Id == id, executeContext.CancellationToken); + + if (entity == null) + { + logger.LogInformation("{Entity} {Id} not found while deleting it, skipping", typeof(TEntity).Name, id); + return executeContext.Completed(); + } + + database.Set().Remove(entity); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Deleted {Entity} {Id} from the database", typeof(TEntity).Name, id); return executeContext.Completed(); } + } - database.Set().Remove(entity); - await database.SaveChangesAsync(executeContext.CancellationToken); + // One concrete activity per entity keeps a separate queue for each of them: + // the endpoint name is the kebab-cased class name. + internal sealed class MarkTrackLifeCycleActivity(IDatabase database, ILogger logger) + : MarkLifeCycleActivity(database, logger) + { + public const string ExecuteEndpointName = "mark-track-life-cycle"; + } - logger.LogInformation("Deleted {Entity} {Id} from the database", typeof(TEntity).Name, id); - return executeContext.Completed(); + internal sealed class MarkPlayListLifeCycleActivity(IDatabase database, ILogger logger) + : MarkLifeCycleActivity(database, logger) + { + public const string ExecuteEndpointName = "mark-play-list-life-cycle"; + } + + internal sealed class MarkAlbumLifeCycleActivity(IDatabase database, ILogger logger) + : MarkLifeCycleActivity(database, logger) + { + public const string ExecuteEndpointName = "mark-album-life-cycle"; + } + + internal sealed class DeleteTrackActivity(IDatabase database, ILogger logger) + : DeleteEntityActivity(database, logger) + { + public const string ExecuteEndpointName = "delete-track"; + } + + internal sealed class DeletePlayListActivity(IDatabase database, ILogger logger) + : DeleteEntityActivity(database, logger) + { + public const string ExecuteEndpointName = "delete-play-list"; + } + + internal sealed class DeleteAlbumActivity(IDatabase database, ILogger logger) + : DeleteEntityActivity(database, logger) + { + public const string ExecuteEndpointName = "delete-album"; } } - -// One concrete activity per entity keeps a separate queue for each of them: -// the endpoint name is the kebab-cased class name. -internal sealed class MarkTrackLifeCycleActivity(IDatabase database, ILogger logger) - : MarkLifeCycleActivity(database, logger) -{ - public const string ExecuteEndpointName = "mark-track-life-cycle"; -} - -internal sealed class MarkPlayListLifeCycleActivity(IDatabase database, ILogger logger) - : MarkLifeCycleActivity(database, logger) -{ - public const string ExecuteEndpointName = "mark-play-list-life-cycle"; -} - -internal sealed class MarkAlbumLifeCycleActivity(IDatabase database, ILogger logger) - : MarkLifeCycleActivity(database, logger) -{ - public const string ExecuteEndpointName = "mark-album-life-cycle"; -} - -internal sealed class DeleteTrackActivity(IDatabase database, ILogger logger) - : DeleteEntityActivity(database, logger) -{ - public const string ExecuteEndpointName = "delete-track"; -} - -internal sealed class DeletePlayListActivity(IDatabase database, ILogger logger) - : DeleteEntityActivity(database, logger) -{ - public const string ExecuteEndpointName = "delete-play-list"; -} - -internal sealed class DeleteAlbumActivity(IDatabase database, ILogger logger) - : DeleteEntityActivity(database, logger) -{ - public const string ExecuteEndpointName = "delete-album"; -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs index 48305af..1022f68 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs @@ -3,37 +3,38 @@ using Microsoft.Extensions.Logging; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Activities.Pictures; - -internal class GeneratePictureWorkflowPathsActivity( - ILogger logger) - : IExecuteActivity +namespace Musify.Infrastructure.MassTransit.Activities.Pictures { - public const string ExecuteEndpointName = "generate-picture-workflow-paths"; - - internal const string ResizedPictureFileExtension = ".webp"; - - public Task Execute(ExecuteContext executeContext) + internal class GeneratePictureWorkflowPathsActivity( + ILogger logger) + : IExecuteActivity { - var workingDirectory = Path.Combine(executeContext.Arguments.TemporaryRootDirectory, Guid.NewGuid().ToString()); + public const string ExecuteEndpointName = "generate-picture-workflow-paths"; - var sourceFilePath = Path.Combine( - workingDirectory, Guid.NewGuid().ToString() + Path.GetExtension(executeContext.Arguments.SourceKey)); - var smallPictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); - var mediumPictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); - var largePictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); + internal const string ResizedPictureFileExtension = ".webp"; - Directory.CreateDirectory(workingDirectory); - - logger.LogDebug("Generated picture workflow paths in {WorkingDirectory}", workingDirectory); - - return Task.FromResult(executeContext.CompletedWithVariables(new Dictionary + public Task Execute(ExecuteContext executeContext) { - [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, - [RoutingSlipVariableNames.Picture.OriginalFilePath] = sourceFilePath, - [RoutingSlipVariableNames.Picture.SmallResizedFilePath] = smallPictureFilePath, - [RoutingSlipVariableNames.Picture.MediumResizedFilePath] = mediumPictureFilePath, - [RoutingSlipVariableNames.Picture.LargeResizedFilePath] = largePictureFilePath - })); + var workingDirectory = Path.Combine(executeContext.Arguments.TemporaryRootDirectory, Guid.NewGuid().ToString()); + + var sourceFilePath = Path.Combine( + workingDirectory, Guid.NewGuid().ToString() + Path.GetExtension(executeContext.Arguments.SourceKey)); + var smallPictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); + var mediumPictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); + var largePictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); + + Directory.CreateDirectory(workingDirectory); + + logger.LogDebug("Generated picture workflow paths in {WorkingDirectory}", workingDirectory); + + return Task.FromResult(executeContext.CompletedWithVariables(new Dictionary + { + [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, + [RoutingSlipVariableNames.Picture.OriginalFilePath] = sourceFilePath, + [RoutingSlipVariableNames.Picture.SmallResizedFilePath] = smallPictureFilePath, + [RoutingSlipVariableNames.Picture.MediumResizedFilePath] = mediumPictureFilePath, + [RoutingSlipVariableNames.Picture.LargeResizedFilePath] = largePictureFilePath + })); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs index 94c7758..89bf098 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs @@ -4,60 +4,61 @@ using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Pictures; - -internal class ResizePictureActivity( - IPictureService pictureHandler, - ILogger logger) - : IActivity +namespace Musify.Infrastructure.MassTransit.Activities.Pictures { - public const string ExecuteEndpointName = "resize-picture"; - - public async Task Execute(ExecuteContext executeContext) + internal class ResizePictureActivity( + IPictureService pictureHandler, + ILogger logger) + : IActivity { - var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.SourceFilePathVariable); - ArgumentNullException.ThrowIfNull(sourceFilePath); - var destinationFilePath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); - ArgumentNullException.ThrowIfNull(destinationFilePath); + public const string ExecuteEndpointName = "resize-picture"; - if (!File.Exists(sourceFilePath)) + public async Task Execute(ExecuteContext executeContext) { - logger.LogWarning("Source file not found: {SourceFilePath}", sourceFilePath); - throw new FileNotFoundException($"Source file not found: {sourceFilePath}"); + var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.SourceFilePathVariable); + ArgumentNullException.ThrowIfNull(sourceFilePath); + var destinationFilePath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); + ArgumentNullException.ThrowIfNull(destinationFilePath); + + if (!File.Exists(sourceFilePath)) + { + logger.LogWarning("Source file not found: {SourceFilePath}", sourceFilePath); + throw new FileNotFoundException($"Source file not found: {sourceFilePath}"); + } + + await using var fileStream = File.OpenRead(sourceFilePath); + + await using var resizedPicture = await pictureHandler.ResizePictureAsWebpAsync( + fileStream, + executeContext.Arguments.Width, + executeContext.Arguments.Height, + executeContext.CancellationToken); + + var destinationDirectory = Path.GetDirectoryName(destinationFilePath); + if (!string.IsNullOrEmpty(destinationDirectory)) + Directory.CreateDirectory(destinationDirectory); + + await using var destinationStream = File.Create(destinationFilePath); + await resizedPicture.CopyToAsync(destinationStream, executeContext.CancellationToken); + + logger.LogDebug("Resized picture from {SourceFilePath} to {DestinationFilePath}", + sourceFilePath, + destinationFilePath); + return executeContext.Completed(new ResizePictureLog(destinationFilePath)); } - await using var fileStream = File.OpenRead(sourceFilePath); - - await using var resizedPicture = await pictureHandler.ResizePictureAsWebpAsync( - fileStream, - executeContext.Arguments.Width, - executeContext.Arguments.Height, - executeContext.CancellationToken); - - var destinationDirectory = Path.GetDirectoryName(destinationFilePath); - if (!string.IsNullOrEmpty(destinationDirectory)) - Directory.CreateDirectory(destinationDirectory); - - await using var destinationStream = File.Create(destinationFilePath); - await resizedPicture.CopyToAsync(destinationStream, executeContext.CancellationToken); - - logger.LogDebug("Resized picture from {SourceFilePath} to {DestinationFilePath}", - sourceFilePath, - destinationFilePath); - return executeContext.Completed(new ResizePictureLog(destinationFilePath)); - } - - public Task Compensate(CompensateContext compensateContext) - { - try + public Task Compensate(CompensateContext compensateContext) { - File.Delete(compensateContext.Log.DestinationFilePath); - return Task.FromResult(compensateContext.Compensated()); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to compensate resized file {DestinationFilePath}", compensateContext.Log.DestinationFilePath); - return Task.FromResult(compensateContext.Failed(exception)); + try + { + File.Delete(compensateContext.Log.DestinationFilePath); + return Task.FromResult(compensateContext.Compensated()); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate resized file {DestinationFilePath}", compensateContext.Log.DestinationFilePath); + return Task.FromResult(compensateContext.Failed(exception)); + } } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePicturesActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePicturesActivity.cs index 76d84f3..3536d55 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePicturesActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePicturesActivity.cs @@ -8,153 +8,154 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Pictures; - -internal sealed record PictureNames(string? Original, string? Small, string? Medium, string? Large); - -/// Stores the processed picture names on the owner and reports it; compensation restores the previous names. -internal abstract class UpdatePicturesActivity( - IDatabase database, - IPublishEndpoint publishEndpoint, - ILogger logger) - : IActivity - where TEntity : class +namespace Musify.Infrastructure.MassTransit.Activities.Pictures { - protected IDatabase Database { get; } = database; + internal sealed record PictureNames(string? Original, string? Small, string? Medium, string? Large); - protected abstract Task FindAsync(Guid id, CancellationToken cancellationToken); - - /// Reads the current names; throws when the entity has no pending picture. - protected abstract PictureNames Read(TEntity entity); - - protected abstract void Write(TEntity entity, PictureNames names, bool succeeded); - - protected abstract object ProcessedEvent(Guid id); - - public async Task Execute(ExecuteContext executeContext) + /// Stores the processed picture names on the owner and reports it; compensation restores the previous names. + internal abstract class UpdatePicturesActivity( + IDatabase database, + IPublishEndpoint publishEndpoint, + ILogger logger) + : IActivity + where TEntity : class { - var arguments = executeContext.Arguments; - var small = RequireVariable(executeContext, arguments.SmallPictureVariable); - var medium = RequireVariable(executeContext, arguments.MediumPictureVariable); - var large = RequireVariable(executeContext, arguments.LargePictureVariable); + protected IDatabase Database { get; } = database; - var entity = await FindAsync(arguments.SubjectId, executeContext.CancellationToken) - ?? throw new InvalidOperationException($"{typeof(TEntity).Name} {arguments.SubjectId} not found"); + protected abstract Task FindAsync(Guid id, CancellationToken cancellationToken); - var previous = Read(entity); - Write( - entity, - new PictureNames( - Path.GetFileName(arguments.OriginalPictureKey), - Path.GetFileName(small), - Path.GetFileName(medium), - Path.GetFileName(large)), - succeeded: true); - await Database.SaveChangesAsync(executeContext.CancellationToken); + /// Reads the current names; throws when the entity has no pending picture. + protected abstract PictureNames Read(TEntity entity); - logger.LogInformation("Updated {Entity} {Id} pictures", typeof(TEntity).Name, arguments.SubjectId); + protected abstract void Write(TEntity entity, PictureNames names, bool succeeded); - await publishEndpoint.Publish(ProcessedEvent(arguments.SubjectId), executeContext.CancellationToken); + protected abstract object ProcessedEvent(Guid id); - return executeContext.Completed(new UpdatePicturesLog( - arguments.SubjectId, previous.Original, previous.Small, previous.Medium, previous.Large)); - } - - public async Task Compensate(CompensateContext compensateContext) - { - var log = compensateContext.Log; - - try + public async Task Execute(ExecuteContext executeContext) { - var entity = await FindAsync(log.SubjectId, compensateContext.CancellationToken); - if (entity == null) + var arguments = executeContext.Arguments; + var small = RequireVariable(executeContext, arguments.SmallPictureVariable); + var medium = RequireVariable(executeContext, arguments.MediumPictureVariable); + var large = RequireVariable(executeContext, arguments.LargePictureVariable); + + var entity = await FindAsync(arguments.SubjectId, executeContext.CancellationToken) + ?? throw new InvalidOperationException($"{typeof(TEntity).Name} {arguments.SubjectId} not found"); + + var previous = Read(entity); + Write( + entity, + new PictureNames( + Path.GetFileName(arguments.OriginalPictureKey), + Path.GetFileName(small), + Path.GetFileName(medium), + Path.GetFileName(large)), + succeeded: true); + await Database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Updated {Entity} {Id} pictures", typeof(TEntity).Name, arguments.SubjectId); + + await publishEndpoint.Publish(ProcessedEvent(arguments.SubjectId), executeContext.CancellationToken); + + return executeContext.Completed(new UpdatePicturesLog( + arguments.SubjectId, previous.Original, previous.Small, previous.Medium, previous.Large)); + } + + public async Task Compensate(CompensateContext compensateContext) + { + var log = compensateContext.Log; + + try + { + var entity = await FindAsync(log.SubjectId, compensateContext.CancellationToken); + if (entity == null) + return compensateContext.Compensated(); + + var previous = new PictureNames(log.PreviousOriginalName, log.PreviousSmallName, log.PreviousMediumName, log.PreviousLargeName); + Write(entity, previous, succeeded: false); + await Database.SaveChangesAsync(compensateContext.CancellationToken); + return compensateContext.Compensated(); - - var previous = new PictureNames(log.PreviousOriginalName, log.PreviousSmallName, log.PreviousMediumName, log.PreviousLargeName); - Write(entity, previous, succeeded: false); - await Database.SaveChangesAsync(compensateContext.CancellationToken); - - return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate {Entity} {Id} pictures", typeof(TEntity).Name, log.SubjectId); + return compensateContext.Failed(exception); + } } - catch (Exception exception) + + protected static PictureNames ReadPictures(EntityPictures? pictures) => + pictures == null + ? throw new InvalidOperationException("The entity has no pending picture upload") + : new PictureNames(pictures.OriginalName, pictures.SmallName, pictures.MediumName, pictures.LargeName); + + protected static void WritePictures(EntityPictures? pictures, PictureNames names) { - logger.LogError(exception, "Failed to compensate {Entity} {Id} pictures", typeof(TEntity).Name, log.SubjectId); - return compensateContext.Failed(exception); + if (pictures == null) + return; + + pictures.OriginalName = names.Original ?? pictures.OriginalName; + pictures.SmallName = names.Small; + pictures.MediumName = names.Medium; + pictures.LargeName = names.Large; } + + private static string RequireVariable(ExecuteContext executeContext, string variableName) => + executeContext.GetVariable(variableName) + ?? throw new InvalidOperationException($"Missing routing slip variable {variableName}"); } - protected static PictureNames ReadPictures(EntityPictures? pictures) => - pictures == null - ? throw new InvalidOperationException("The entity has no pending picture upload") - : new PictureNames(pictures.OriginalName, pictures.SmallName, pictures.MediumName, pictures.LargeName); - - protected static void WritePictures(EntityPictures? pictures, PictureNames names) + internal sealed class UpdateTrackPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) + : UpdatePicturesActivity(database, publishEndpoint, logger) { - if (pictures == null) - return; + public const string ExecuteEndpointName = "update-track-picture"; - pictures.OriginalName = names.Original ?? pictures.OriginalName; - pictures.SmallName = names.Small; - pictures.MediumName = names.Medium; - pictures.LargeName = names.Large; + protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => + Database.Tracks.SingleOrDefaultAsync(track => track.Id == id, cancellationToken); + + protected override PictureNames Read(Track track) => + new(track.Pictures.OriginalName, track.Pictures.SmallName, track.Pictures.MediumName, track.Pictures.LargeName); + + protected override void Write(Track track, PictureNames names, bool succeeded) + { + track.Pictures.OriginalName = names.Original; + track.Pictures.SmallName = names.Small; + track.Pictures.MediumName = names.Medium; + track.Pictures.LargeName = names.Large; + track.Pictures.ProcessingStatus = succeeded ? ProcessingStatus.Completed : ProcessingStatus.Failed; + } + + protected override object ProcessedEvent(Guid id) => new TrackPictureProcessed(id); } - private static string RequireVariable(ExecuteContext executeContext, string variableName) => - executeContext.GetVariable(variableName) - ?? throw new InvalidOperationException($"Missing routing slip variable {variableName}"); -} - -internal sealed class UpdateTrackPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) - : UpdatePicturesActivity(database, publishEndpoint, logger) -{ - public const string ExecuteEndpointName = "update-track-picture"; - - protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => - Database.Tracks.SingleOrDefaultAsync(track => track.Id == id, cancellationToken); - - protected override PictureNames Read(Track track) => - new(track.Pictures.OriginalName, track.Pictures.SmallName, track.Pictures.MediumName, track.Pictures.LargeName); - - protected override void Write(Track track, PictureNames names, bool succeeded) + internal sealed class UpdatePlayListPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) + : UpdatePicturesActivity(database, publishEndpoint, logger) { - track.Pictures.OriginalName = names.Original; - track.Pictures.SmallName = names.Small; - track.Pictures.MediumName = names.Medium; - track.Pictures.LargeName = names.Large; - track.Pictures.ProcessingStatus = succeeded ? ProcessingStatus.Completed : ProcessingStatus.Failed; + public const string ExecuteEndpointName = "update-play-list-picture"; + + protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => + Database.PlayLists.SingleOrDefaultAsync(playList => playList.Id == id, cancellationToken); + + protected override PictureNames Read(PlayList playList) => ReadPictures(playList.Pictures); + + protected override void Write(PlayList playList, PictureNames names, bool succeeded) => + WritePictures(playList.Pictures, names); + + protected override object ProcessedEvent(Guid id) => new PlayListPictureProcessed(id); } - protected override object ProcessedEvent(Guid id) => new TrackPictureProcessed(id); -} + internal sealed class UpdateAlbumPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) + : UpdatePicturesActivity(database, publishEndpoint, logger) + { + public const string ExecuteEndpointName = "update-album-picture"; -internal sealed class UpdatePlayListPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) - : UpdatePicturesActivity(database, publishEndpoint, logger) -{ - public const string ExecuteEndpointName = "update-play-list-picture"; + protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => + Database.Albums.SingleOrDefaultAsync(album => album.Id == id, cancellationToken); - protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => - Database.PlayLists.SingleOrDefaultAsync(playList => playList.Id == id, cancellationToken); + protected override PictureNames Read(Album album) => ReadPictures(album.Pictures); - protected override PictureNames Read(PlayList playList) => ReadPictures(playList.Pictures); + protected override void Write(Album album, PictureNames names, bool succeeded) => + WritePictures(album.Pictures, names); - protected override void Write(PlayList playList, PictureNames names, bool succeeded) => - WritePictures(playList.Pictures, names); - - protected override object ProcessedEvent(Guid id) => new PlayListPictureProcessed(id); -} - -internal sealed class UpdateAlbumPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) - : UpdatePicturesActivity(database, publishEndpoint, logger) -{ - public const string ExecuteEndpointName = "update-album-picture"; - - protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => - Database.Albums.SingleOrDefaultAsync(album => album.Id == id, cancellationToken); - - protected override PictureNames Read(Album album) => ReadPictures(album.Pictures); - - protected override void Write(Album album, PictureNames names, bool succeeded) => - WritePictures(album.Pictures, names); - - protected override object ProcessedEvent(Guid id) => new AlbumPictureProcessed(id); + protected override object ProcessedEvent(Guid id) => new AlbumPictureProcessed(id); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs index c1b104c..dc2b771 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs @@ -3,29 +3,30 @@ using Microsoft.Extensions.Logging; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.PlayLists; - -internal class PublishPlayListPictureProcessingEventActivity( - IPublishEndpoint publishEndpoint, - ILogger logger) - : IExecuteActivity +namespace Musify.Infrastructure.MassTransit.Activities.PlayLists { - public const string ExecuteEndpointName = "publish-play-list-picture-processing-event"; - - public async Task Execute(ExecuteContext executeContext) + internal class PublishPlayListPictureProcessingEventActivity( + IPublishEndpoint publishEndpoint, + ILogger logger) + : IExecuteActivity { - var arguments = executeContext.Arguments; + public const string ExecuteEndpointName = "publish-play-list-picture-processing-event"; - await publishEndpoint.Publish( - new UpdatePlayListPictureEvent( - arguments.PlayListId, - arguments.Bucket, - arguments.FinalPictureKey, - arguments.Sizes), - executeContext.CancellationToken); + public async Task Execute(ExecuteContext executeContext) + { + var arguments = executeContext.Arguments; - logger.LogInformation("Published picture processing event for playlist {PlayListId}", arguments.PlayListId); + await publishEndpoint.Publish( + new UpdatePlayListPictureEvent( + arguments.PlayListId, + arguments.Bucket, + arguments.FinalPictureKey, + arguments.Sizes), + executeContext.CancellationToken); - return executeContext.Completed(); + logger.LogInformation("Published picture processing event for playlist {PlayListId}", arguments.PlayListId); + + return executeContext.Completed(); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs index 8cf6b1f..9b88b0e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs @@ -3,38 +3,39 @@ using Microsoft.Extensions.Logging; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.Tracks; - -internal class PublishTrackProcessingEventsActivity( - IPublishEndpoint publishEndpoint, - ILogger logger) - : IExecuteActivity +namespace Musify.Infrastructure.MassTransit.Activities.Tracks { - public const string ExecuteEndpointName = "publish-track-processing-events"; - - public async Task Execute(ExecuteContext executeContext) + internal class PublishTrackProcessingEventsActivity( + IPublishEndpoint publishEndpoint, + ILogger logger) + : IExecuteActivity { - var arguments = executeContext.Arguments; + public const string ExecuteEndpointName = "publish-track-processing-events"; - await publishEndpoint.Publish( - new UpdateTrackPictureEvent( - arguments.TrackId, - arguments.Bucket, - arguments.FinalPictureKey, - arguments.Sizes), - executeContext.CancellationToken); + public async Task Execute(ExecuteContext executeContext) + { + var arguments = executeContext.Arguments; - await publishEndpoint.Publish( - new UpdateTrackAudioEvent( - arguments.TrackId, - arguments.Bucket, - arguments.FinalAudioKey, - arguments.Bucket, - arguments.AudioDestinationFolderKey), - executeContext.CancellationToken); + await publishEndpoint.Publish( + new UpdateTrackPictureEvent( + arguments.TrackId, + arguments.Bucket, + arguments.FinalPictureKey, + arguments.Sizes), + executeContext.CancellationToken); - logger.LogInformation("Published processing events for track {TrackId}", arguments.TrackId); + await publishEndpoint.Publish( + new UpdateTrackAudioEvent( + arguments.TrackId, + arguments.Bucket, + arguments.FinalAudioKey, + arguments.Bucket, + arguments.AudioDestinationFolderKey), + executeContext.CancellationToken); - return executeContext.Completed(); + logger.LogInformation("Published processing events for track {TrackId}", arguments.TrackId); + + return executeContext.Completed(); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs index 24d8aa9..1582d32 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs @@ -6,56 +6,57 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.UploadIntents; - -internal class ConsumeUploadIntentsActivity( - IDatabase database, - ILogger logger) - : IActivity +namespace Musify.Infrastructure.MassTransit.Activities.UploadIntents { - public const string ExecuteEndpointName = "consume-upload-intents"; - - public async Task Execute(ExecuteContext executeContext) + internal class ConsumeUploadIntentsActivity( + IDatabase database, + ILogger logger) + : IActivity { - var intentIds = executeContext.Arguments.IntentIds; + public const string ExecuteEndpointName = "consume-upload-intents"; - var intents = await database.UploadIntents - .Where(i => intentIds.Contains(i.Id)) - .ToListAsync(executeContext.CancellationToken); - - foreach (var intent in intents) - intent.Status = UploadIntentStatus.Consumed; - - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Marked {Count} upload intent(s) as consumed", intents.Count); - - return executeContext.Completed(new ConsumeUploadIntentsLog(intentIds)); - } - - public async Task Compensate(CompensateContext compensateContext) - { - try + public async Task Execute(ExecuteContext executeContext) { - var intentIds = compensateContext.Log.IntentIds; + var intentIds = executeContext.Arguments.IntentIds; var intents = await database.UploadIntents .Where(i => intentIds.Contains(i.Id)) - .ToListAsync(compensateContext.CancellationToken); + .ToListAsync(executeContext.CancellationToken); foreach (var intent in intents) - intent.Status = UploadIntentStatus.Issued; + intent.Status = UploadIntentStatus.Consumed; - await database.SaveChangesAsync(compensateContext.CancellationToken); + await database.SaveChangesAsync(executeContext.CancellationToken); - logger.LogInformation("Reverted {Count} upload intent(s) back to Issued", intents.Count); + logger.LogInformation("Marked {Count} upload intent(s) as consumed", intents.Count); - return compensateContext.Compensated(); + return executeContext.Completed(new ConsumeUploadIntentsLog(intentIds)); } - catch (Exception exception) + + public async Task Compensate(CompensateContext compensateContext) { - logger.LogError(exception, "Failed to compensate upload intent consumption"); - return compensateContext.Failed(exception); + try + { + var intentIds = compensateContext.Log.IntentIds; + + var intents = await database.UploadIntents + .Where(i => intentIds.Contains(i.Id)) + .ToListAsync(compensateContext.CancellationToken); + + foreach (var intent in intents) + intent.Status = UploadIntentStatus.Issued; + + await database.SaveChangesAsync(compensateContext.CancellationToken); + + logger.LogInformation("Reverted {Count} upload intent(s) back to Issued", intents.Count); + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate upload intent consumption"); + return compensateContext.Failed(exception); + } } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs index d133085..70cd9c2 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs @@ -1,4 +1,5 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record ConsumeUploadIntentsArguments( - Guid[] IntentIds); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record ConsumeUploadIntentsArguments( + Guid[] IntentIds); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs index d73c5ea..ade186a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs @@ -1,7 +1,8 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record CopyFileInBucketArguments( - string SourceBucket, - string SourceKey, - string DestinationBucket, - string DestinationKey); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record CopyFileInBucketArguments( + string SourceBucket, + string SourceKey, + string DestinationBucket, + string DestinationKey); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteEntityArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteEntityArguments.cs index b84ea8b..257ceee 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteEntityArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteEntityArguments.cs @@ -1,3 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -internal record DeleteEntityArguments(Guid Id); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + internal record DeleteEntityArguments(Guid Id); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs index 853ba02..e687d24 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs @@ -1,6 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record DownloadFileFromBucketArguments( - string Bucket, - string Key, - string DestinationFilePathVariable); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record DownloadFileFromBucketArguments( + string Bucket, + string Key, + string DestinationFilePathVariable); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs index 885fe7f..0786562 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs @@ -1,6 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record GenerateAudioWorkflowPathsArguments( - Guid TrackId, - string TemporaryRootDirectory, - string SourceKey); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record GenerateAudioWorkflowPathsArguments( + Guid TrackId, + string TemporaryRootDirectory, + string SourceKey); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs index 65be2e6..9a71d63 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs @@ -1,5 +1,6 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record GeneratePictureWorkflowPathsArguments( - string TemporaryRootDirectory, - string SourceKey); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record GeneratePictureWorkflowPathsArguments( + string TemporaryRootDirectory, + string SourceKey); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkLifeCycleArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkLifeCycleArguments.cs index 53bd438..f886919 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkLifeCycleArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkLifeCycleArguments.cs @@ -1,5 +1,6 @@ using Musify.Domain.ValueObjects; -namespace Musify.Infrastructure.MassTransit.Arguments; - -internal record MarkLifeCycleArguments(Guid Id, LifeCycleStatus Status); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + internal record MarkLifeCycleArguments(Guid Id, LifeCycleStatus Status); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs index 10cfa7a..b2efad7 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs @@ -1,9 +1,10 @@ using Musify.Application.Shared; -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record PublishAlbumPictureProcessingEventArguments( - Guid AlbumId, - string Bucket, - string FinalPictureKey, - ImageSizes Sizes); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record PublishAlbumPictureProcessingEventArguments( + Guid AlbumId, + string Bucket, + string FinalPictureKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs index b4831c0..8168dbf 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs @@ -1,9 +1,10 @@ using Musify.Application.Shared; -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record PublishPlayListPictureProcessingEventArguments( - Guid PlayListId, - string Bucket, - string FinalPictureKey, - ImageSizes Sizes); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record PublishPlayListPictureProcessingEventArguments( + Guid PlayListId, + string Bucket, + string FinalPictureKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs index 6bc151b..e711bc8 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs @@ -1,11 +1,12 @@ using Musify.Application.Shared; -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record PublishTrackProcessingEventsArguments( - Guid TrackId, - string Bucket, - string FinalPictureKey, - string FinalAudioKey, - string AudioDestinationFolderKey, - ImageSizes Sizes); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record PublishTrackProcessingEventsArguments( + Guid TrackId, + string Bucket, + string FinalPictureKey, + string FinalAudioKey, + string AudioDestinationFolderKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs index ceb2c64..c0e803f 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs @@ -1,5 +1,6 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record RemoveFileFromBucketArguments( - string Bucket, - string Key); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record RemoveFileFromBucketArguments( + string Bucket, + string Key); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs index 04fca58..022fc7c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs @@ -1,7 +1,8 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record ResizePictureLocalArguments( - string SourceFilePathVariable, - string DestinationFilePathVariable, - int Width, - int Height); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record ResizePictureLocalArguments( + string SourceFilePathVariable, + string DestinationFilePathVariable, + int Width, + int Height); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs index 9b33211..1533480 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs @@ -1,6 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record TranscodeAudioArguments( - Guid TrackId, - string SourceFilePathVariable, - string WorkingDirectoryVariable); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record TranscodeAudioArguments( + Guid TrackId, + string SourceFilePathVariable, + string WorkingDirectoryVariable); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs index f1e34ae..dcc61fd 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs @@ -1,6 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record TransferFilesToBucketArguments( - string DestinationBucket, - string SourceDirectoryVariable, - string DestinationKey); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record TransferFilesToBucketArguments( + string DestinationBucket, + string SourceDirectoryVariable, + string DestinationKey); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePicturesArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePicturesArguments.cs index 18270ed..06e6115 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePicturesArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePicturesArguments.cs @@ -1,8 +1,9 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record UpdatePicturesArguments( - Guid SubjectId, - string OriginalPictureKey, - string SmallPictureVariable, - string MediumPictureVariable, - string LargePictureVariable); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record UpdatePicturesArguments( + Guid SubjectId, + string OriginalPictureKey, + string SmallPictureVariable, + string MediumPictureVariable, + string LargePictureVariable); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs index f9c46e0..b284c4e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs @@ -1,6 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record UpdateTrackAudioArguments( - Guid TrackId, - string AudioFolderKey, - string DurationSecondsVariable); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record UpdateTrackAudioArguments( + Guid TrackId, + string AudioFolderKey, + string DurationSecondsVariable); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs index 66edbc2..a34ab21 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs @@ -1,6 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record UploadFileToBucketArguments( - string FilePathVariable, - string DestinationBucket, - string DestinationRoute); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record UploadFileToBucketArguments( + string FilePathVariable, + string DestinationBucket, + string DestinationRoute); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs index a8b46e2..f799916 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs @@ -3,16 +3,17 @@ using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Activities.LifeCycle; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class AlbumProcessingFailedConsumer(IBus bus) : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) + public class AlbumProcessingFailedConsumer(IBus bus) : IConsumer { - var message = context.Message; + public Task Consume(ConsumeContext context) + { + var message = context.Message; - return ProcessingFailedCleanup.ExecuteAsync( - bus, message.AlbumId, ActivityNames.MarkAlbumAsFailed, MarkAlbumLifeCycleActivity.ExecuteEndpointName, message.Bucket, - (ActivityNames.RemoveAlbumOriginalPicture, message.PictureKey)); + return ProcessingFailedCleanup.ExecuteAsync( + bus, message.AlbumId, ActivityNames.MarkAlbumAsFailed, MarkAlbumLifeCycleActivity.ExecuteEndpointName, message.Bucket, + (ActivityNames.RemoveAlbumOriginalPicture, message.PictureKey)); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs index 1b6e0c5..1c723ba 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class CreateAlbumConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class CreateAlbumConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs index 012df87..96b5585 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class CreatePlayListConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class CreatePlayListConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs index 9a7cb5e..6d46e90 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class CreateTrackConsumer(IBus bus, CreateTrackRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class CreateTrackConsumer(IBus bus, CreateTrackRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs index 4802a43..42971c8 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs @@ -2,17 +2,18 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class DeleteAlbumConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public async Task Consume(ConsumeContext context) + public class DeleteAlbumConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - var message = context.Message; - var routingSlip = await routingSlipBuilder.BuildAlbumAsync( - message.AlbumId, message.UserId, context.CorrelationId, context.CancellationToken); + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + var routingSlip = await routingSlipBuilder.BuildAlbumAsync( + message.AlbumId, message.UserId, context.CorrelationId, context.CancellationToken); - await bus.Execute(routingSlip.Build()); + await bus.Execute(routingSlip.Build()); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs index 8175eba..35e8863 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs @@ -2,17 +2,18 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class DeletePlayListConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public async Task Consume(ConsumeContext context) + public class DeletePlayListConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - var message = context.Message; - var routingSlip = await routingSlipBuilder.BuildPlayListAsync( - message.PlayListId, message.UserId, context.CorrelationId, context.CancellationToken); + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + var routingSlip = await routingSlipBuilder.BuildPlayListAsync( + message.PlayListId, message.UserId, context.CorrelationId, context.CancellationToken); - await bus.Execute(routingSlip.Build()); + await bus.Execute(routingSlip.Build()); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs index 4d2b123..90a69be 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs @@ -2,17 +2,18 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class DeleteTrackConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public async Task Consume(ConsumeContext context) + public class DeleteTrackConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - var message = context.Message; - var routingSlip = await routingSlipBuilder.BuildTrackAsync( - message.TrackId, message.UserId, context.CorrelationId, context.CancellationToken); + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + var routingSlip = await routingSlipBuilder.BuildTrackAsync( + message.TrackId, message.UserId, context.CorrelationId, context.CancellationToken); - await bus.Execute(routingSlip.Build()); + await bus.Execute(routingSlip.Build()); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs index 78bcb62..0cdf562 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs @@ -3,16 +3,17 @@ using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Activities.LifeCycle; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class PlayListProcessingFailedConsumer(IBus bus) : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) + public class PlayListProcessingFailedConsumer(IBus bus) : IConsumer { - var message = context.Message; + public Task Consume(ConsumeContext context) + { + var message = context.Message; - return ProcessingFailedCleanup.ExecuteAsync( - bus, message.PlayListId, ActivityNames.MarkPlayListAsFailed, MarkPlayListLifeCycleActivity.ExecuteEndpointName, message.Bucket, - (ActivityNames.RemovePlayListOriginalPicture, message.PictureKey)); + return ProcessingFailedCleanup.ExecuteAsync( + bus, message.PlayListId, ActivityNames.MarkPlayListAsFailed, MarkPlayListLifeCycleActivity.ExecuteEndpointName, message.Bucket, + (ActivityNames.RemovePlayListOriginalPicture, message.PictureKey)); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingFailedCleanup.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingFailedCleanup.cs index 434ad9b..e717b55 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingFailedCleanup.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingFailedCleanup.cs @@ -3,32 +3,33 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Consumers; - -internal static class ProcessingFailedCleanup +namespace Musify.Infrastructure.MassTransit.Consumers { - /// Removes whatever files were left behind and marks the entity as failed. - public static Task ExecuteAsync( - IBus bus, - Guid subjectId, - string markStepName, - string markEndpointName, - string? bucket, - params (string StepName, string? Key)[] files) + internal static class ProcessingFailedCleanup { - var builder = RoutingSlips.Create(correlationId: null); - - if (!string.IsNullOrWhiteSpace(bucket)) + /// Removes whatever files were left behind and marks the entity as failed. + public static Task ExecuteAsync( + IBus bus, + Guid subjectId, + string markStepName, + string markEndpointName, + string? bucket, + params (string StepName, string? Key)[] files) { - foreach (var (stepName, key) in files) + var builder = RoutingSlips.Create(correlationId: null); + + if (!string.IsNullOrWhiteSpace(bucket)) { - if (!string.IsNullOrWhiteSpace(key)) - builder.AddRemoveFile(stepName, bucket, key); + foreach (var (stepName, key) in files) + { + if (!string.IsNullOrWhiteSpace(key)) + builder.AddRemoveFile(stepName, bucket, key); + } } + + builder.AddStep(markStepName, markEndpointName, new MarkLifeCycleArguments(subjectId, LifeCycleStatus.Failed)); + + return bus.Execute(builder.Build()); } - - builder.AddStep(markStepName, markEndpointName, new MarkLifeCycleArguments(subjectId, LifeCycleStatus.Failed)); - - return bus.Execute(builder.Build()); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs index c41dd54..2ffe99c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs @@ -3,58 +3,59 @@ using MassTransit.Courier.Contracts; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class ProcessingSlipFaultConsumer : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public const string QueueName = "processing-slip-fault"; - - public async Task Consume(ConsumeContext context) + public class ProcessingSlipFaultConsumer : IConsumer { - var variables = context.Message.Variables; + public const string QueueName = "processing-slip-fault"; - if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.ProcessKind, out var kindObject)) - return; - - if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.SubjectId, out var subjectObject) - || !Guid.TryParse(subjectObject?.ToString(), out var subjectId)) + public async Task Consume(ConsumeContext context) { - return; + var variables = context.Message.Variables; + + if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.ProcessKind, out var kindObject)) + return; + + if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.SubjectId, out var subjectObject) + || !Guid.TryParse(subjectObject?.ToString(), out var subjectId)) + { + return; + } + + switch (kindObject?.ToString()) + { + case RoutingSlipVariableNames.ProcessKinds.TrackPicture: + await context.Publish(new TrackPictureProcessingFailed(subjectId)); + break; + case RoutingSlipVariableNames.ProcessKinds.TrackAudio: + await context.Publish(new TrackAudioProcessingFailed(subjectId)); + break; + case RoutingSlipVariableNames.ProcessKinds.PlayListPicture: + await context.Publish(new PlayListPictureProcessingFailed(subjectId)); + break; + case RoutingSlipVariableNames.ProcessKinds.AlbumPicture: + await context.Publish(new AlbumPictureProcessingFailed(subjectId)); + break; + case RoutingSlipVariableNames.ProcessKinds.TrackCreation: + await context.Publish(new TrackProcessingFailed( + subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), + Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey), + Variable(variables, RoutingSlipVariableNames.Workflow.AudioKey))); + break; + case RoutingSlipVariableNames.ProcessKinds.AlbumCreation: + await context.Publish(new AlbumProcessingFailed( + subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), + Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey))); + break; + case RoutingSlipVariableNames.ProcessKinds.PlayListCreation: + await context.Publish(new PlayListProcessingFailed( + subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), + Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey))); + break; + } } - switch (kindObject?.ToString()) - { - case RoutingSlipVariableNames.ProcessKinds.TrackPicture: - await context.Publish(new TrackPictureProcessingFailed(subjectId)); - break; - case RoutingSlipVariableNames.ProcessKinds.TrackAudio: - await context.Publish(new TrackAudioProcessingFailed(subjectId)); - break; - case RoutingSlipVariableNames.ProcessKinds.PlayListPicture: - await context.Publish(new PlayListPictureProcessingFailed(subjectId)); - break; - case RoutingSlipVariableNames.ProcessKinds.AlbumPicture: - await context.Publish(new AlbumPictureProcessingFailed(subjectId)); - break; - case RoutingSlipVariableNames.ProcessKinds.TrackCreation: - await context.Publish(new TrackProcessingFailed( - subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), - Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey), - Variable(variables, RoutingSlipVariableNames.Workflow.AudioKey))); - break; - case RoutingSlipVariableNames.ProcessKinds.AlbumCreation: - await context.Publish(new AlbumProcessingFailed( - subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), - Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey))); - break; - case RoutingSlipVariableNames.ProcessKinds.PlayListCreation: - await context.Publish(new PlayListProcessingFailed( - subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), - Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey))); - break; - } + private static string? Variable(IDictionary variables, string name) => + variables.TryGetValue(name, out var value) ? value?.ToString() : null; } - - private static string? Variable(IDictionary variables, string name) => - variables.TryGetValue(name, out var value) ? value?.ToString() : null; } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs index 0239bd4..6d253ef 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs @@ -3,41 +3,42 @@ using MassTransit.Courier.Contracts; using Microsoft.Extensions.Logging; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class RoutingSlipCleanUpConsumer( - ILogger logger) : - IConsumer, - IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public const string QueueName = "routing-slip-clean-up"; - - public Task Consume(ConsumeContext context) - => DeleteFolder(context.Message.Variables); - - public Task Consume(ConsumeContext context) - => DeleteFolder(context.Message.Variables); - - private Task DeleteFolder(IDictionary variables) + public class RoutingSlipCleanUpConsumer( + ILogger logger) : + IConsumer, + IConsumer { - if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.TemporalDirectory, out var pathObject)) - return Task.CompletedTask; + public const string QueueName = "routing-slip-clean-up"; - var pathString = pathObject.ToString(); + public Task Consume(ConsumeContext context) + => DeleteFolder(context.Message.Variables); - if (string.IsNullOrEmpty(pathString)) - return Task.CompletedTask; + public Task Consume(ConsumeContext context) + => DeleteFolder(context.Message.Variables); - try + private Task DeleteFolder(IDictionary variables) { - Directory.Delete(pathString, recursive: true); - logger.LogInformation("Deleted temporal directory at path {Path}", pathString); - } - catch (DirectoryNotFoundException) - { - logger.LogWarning("Temporal directory at path {Path} was not found for deletion", pathString); - } + if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.TemporalDirectory, out var pathObject)) + return Task.CompletedTask; - return Task.CompletedTask; + var pathString = pathObject.ToString(); + + if (string.IsNullOrEmpty(pathString)) + return Task.CompletedTask; + + try + { + Directory.Delete(pathString, recursive: true); + logger.LogInformation("Deleted temporal directory at path {Path}", pathString); + } + catch (DirectoryNotFoundException) + { + logger.LogWarning("Temporal directory at path {Path} was not found for deletion", pathString); + } + + return Task.CompletedTask; + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs index c2f5a3b..83852ef 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs @@ -3,17 +3,18 @@ using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Activities.LifeCycle; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class TrackProcessingFailedConsumer(IBus bus) : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) + public class TrackProcessingFailedConsumer(IBus bus) : IConsumer { - var message = context.Message; + public Task Consume(ConsumeContext context) + { + var message = context.Message; - return ProcessingFailedCleanup.ExecuteAsync( - bus, message.TrackId, ActivityNames.MarkTrackAsFailed, MarkTrackLifeCycleActivity.ExecuteEndpointName, message.Bucket, - (ActivityNames.RemoveTrackOriginalPicture, message.PictureKey), - (ActivityNames.RemoveTrackOriginalAudio, message.AudioKey)); + return ProcessingFailedCleanup.ExecuteAsync( + bus, message.TrackId, ActivityNames.MarkTrackAsFailed, MarkTrackLifeCycleActivity.ExecuteEndpointName, message.Bucket, + (ActivityNames.RemoveTrackOriginalPicture, message.PictureKey), + (ActivityNames.RemoveTrackOriginalAudio, message.AudioKey)); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs index ad9f8f3..caeb800 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class UpdateAlbumPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class UpdateAlbumPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs index 4069246..ec1203f 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class UpdateAlbumPictureSourceConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class UpdateAlbumPictureSourceConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs index d89df5e..079493c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class UpdatePlayListPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class UpdatePlayListPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs index 8082d79..177685a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class UpdatePlayListPictureSourceConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class UpdatePlayListPictureSourceConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs index 221dc24..5570cec 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class UpdateTrackAudioConsumer(IBus bus, AudioWorkflowRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class UpdateTrackAudioConsumer(IBus bus, AudioWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs index d26de5a..079c8e2 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class UpdateTrackPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class UpdateTrackPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs index 0053b0d..9561290 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs @@ -13,62 +13,63 @@ using Musify.Infrastructure.MassTransit.Consumers; using Musify.Infrastructure.MassTransit.Logs; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit; - -public static partial class MassTransitDependencyInjection +namespace Musify.Infrastructure.MassTransit { - private static void RegisterRoutingSlipBuilders(IServiceCollection services) + public static partial class MassTransitDependencyInjection { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - } + private static void RegisterRoutingSlipBuilders(IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } - private static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options) - { - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); + private static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options) + { + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs index 3ddf79f..24940fb 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs @@ -7,107 +7,108 @@ using Musify.Infrastructure.Configuration; using Musify.Infrastructure.MassTransit.Sagas; using Musify.Infrastructure.Persistence; -namespace Musify.Infrastructure.MassTransit; - -public static partial class MassTransitDependencyInjection +namespace Musify.Infrastructure.MassTransit { - public static IServiceCollection AddMassTransitClient(this IServiceCollection services, IConfiguration configuration) + public static partial class MassTransitDependencyInjection { - services.AddValidatedOptions(configuration); - services.AddScoped(); - - services.AddMassTransit(options => + public static IServiceCollection AddMassTransitClient(this IServiceCollection services, IConfiguration configuration) { - AddOutbox(options); + services.AddValidatedOptions(configuration); + services.AddScoped(); - options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => - ConfigureRabbitMqHost( - busFactoryConfigurator, - busRegistrationContext.GetRequiredService())); - }); - - return services; - } - - public static IServiceCollection AddMassTransitConsumers(this IServiceCollection services, IConfiguration configuration) - { - services.AddValidatedOptions(configuration); - - services.AddValidatedOptions(configuration); - services.AddScoped(); - - RegisterRoutingSlipBuilders(services); - - services.AddMassTransit(options => - { - AddOutbox(options); - - options.SetKebabCaseEndpointNameFormatter(); - - RegisterConsumersAndActivities(options); - - options.AddSagaStateMachine() - .EntityFrameworkRepository(ConfigureSagaRepository); - - options.AddSagaStateMachine() - .EntityFrameworkRepository(ConfigureSagaRepository); - - options.AddSagaStateMachine() - .EntityFrameworkRepository(ConfigureSagaRepository); - - options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) => + services.AddMassTransit(options => { - UseStandardRetry(endpointConfigurator); - endpointConfigurator.UseEntityFrameworkOutbox(registrationContext); + AddOutbox(options); + + options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => + ConfigureRabbitMqHost( + busFactoryConfigurator, + busRegistrationContext.GetRequiredService())); }); - options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => + return services; + } + + public static IServiceCollection AddMassTransitConsumers(this IServiceCollection services, IConfiguration configuration) + { + services.AddValidatedOptions(configuration); + + services.AddValidatedOptions(configuration); + services.AddScoped(); + + RegisterRoutingSlipBuilders(services); + + services.AddMassTransit(options => { - ConfigureRabbitMqHost( - busFactoryConfigurator, - busRegistrationContext.GetRequiredService()); + AddOutbox(options); - busFactoryConfigurator.ConfigureEndpoints(busRegistrationContext); + options.SetKebabCaseEndpointNameFormatter(); + + RegisterConsumersAndActivities(options); + + options.AddSagaStateMachine() + .EntityFrameworkRepository(ConfigureSagaRepository); + + options.AddSagaStateMachine() + .EntityFrameworkRepository(ConfigureSagaRepository); + + options.AddSagaStateMachine() + .EntityFrameworkRepository(ConfigureSagaRepository); + + options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) => + { + UseStandardRetry(endpointConfigurator); + endpointConfigurator.UseEntityFrameworkOutbox(registrationContext); + }); + + options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => + { + ConfigureRabbitMqHost( + busFactoryConfigurator, + busRegistrationContext.GetRequiredService()); + + busFactoryConfigurator.ConfigureEndpoints(busRegistrationContext); + }); }); - }); - return services; - } + return services; + } - private static void AddOutbox(IBusRegistrationConfigurator options) => - options.AddEntityFrameworkOutbox(outbox => + private static void AddOutbox(IBusRegistrationConfigurator options) => + options.AddEntityFrameworkOutbox(outbox => + { + outbox.UsePostgres(); + outbox.UseBusOutbox(); + + outbox.QueryDelay = TimeSpan.FromSeconds(1); + outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); + }); + + private static void ConfigureSagaRepository(IEntityFrameworkSagaRepositoryConfigurator repository) + where TSaga : class, ISaga { - outbox.UsePostgres(); - outbox.UseBusOutbox(); + repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; + repository.ExistingDbContext(); + repository.UsePostgres(); + } - outbox.QueryDelay = TimeSpan.FromSeconds(1); - outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); - }); + private static void UseStandardRetry(IReceiveEndpointConfigurator endpointConfigurator) => + endpointConfigurator.UseMessageRetry(retryConfigurator => + retryConfigurator.Exponential( + retryLimit: 5, + minInterval: TimeSpan.FromSeconds(1), + maxInterval: TimeSpan.FromSeconds(30), + intervalDelta: TimeSpan.FromSeconds(2))); - private static void ConfigureSagaRepository(IEntityFrameworkSagaRepositoryConfigurator repository) - where TSaga : class, ISaga - { - repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; - repository.ExistingDbContext(); - repository.UsePostgres(); - } - - private static void UseStandardRetry(IReceiveEndpointConfigurator endpointConfigurator) => - endpointConfigurator.UseMessageRetry(retryConfigurator => - retryConfigurator.Exponential( - retryLimit: 5, - minInterval: TimeSpan.FromSeconds(1), - maxInterval: TimeSpan.FromSeconds(30), - intervalDelta: TimeSpan.FromSeconds(2))); - - private static void ConfigureRabbitMqHost( - IRabbitMqBusFactoryConfigurator busFactoryConfigurator, - MassTransitConfiguration massTransitConfiguration) - { - busFactoryConfigurator.Host(massTransitConfiguration.Address, options => + private static void ConfigureRabbitMqHost( + IRabbitMqBusFactoryConfigurator busFactoryConfigurator, + MassTransitConfiguration massTransitConfiguration) { - options.Username(massTransitConfiguration.Username); - options.Password(massTransitConfiguration.Password); - }); + busFactoryConfigurator.Host(massTransitConfiguration.Address, options => + { + options.Username(massTransitConfiguration.Username); + options.Password(massTransitConfiguration.Password); + }); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs index bce11e7..275ddd5 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs @@ -1,4 +1,5 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record ConsumeUploadIntentsLog( - Guid[] IntentIds); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record ConsumeUploadIntentsLog( + Guid[] IntentIds); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs index 0ee7c18..609258a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs @@ -1,5 +1,6 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record CopyFileInBucketLog( - string DestinationBucket, - string DestinationKey); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record CopyFileInBucketLog( + string DestinationBucket, + string DestinationKey); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs index 84769ee..0ecef2b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs @@ -1,6 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record DownloadFileFromBucketLog( - string Bucket, - string Key, - string DestinationFilePath); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record DownloadFileFromBucketLog( + string Bucket, + string Key, + string DestinationFilePath); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs index ed10990..9a9c223 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs @@ -1,3 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record GeneratePictureWorkflowPathsLog(string WorkingDirectory); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record GeneratePictureWorkflowPathsLog(string WorkingDirectory); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/ResizePictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/ResizePictureLog.cs index 32349fb..f255d51 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/ResizePictureLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/ResizePictureLog.cs @@ -1,3 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record ResizePictureLog(string DestinationFilePath); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record ResizePictureLog(string DestinationFilePath); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs index 7bcf413..c85933a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs @@ -1,3 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record TranscodeAudioLog(string WorkingDirectory); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record TranscodeAudioLog(string WorkingDirectory); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs index ba61c9a..5db2002 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs @@ -1,3 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record TransferFilesToBucketLog(string DestinationBucket, string[] UploadedKeys); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record TransferFilesToBucketLog(string DestinationBucket, string[] UploadedKeys); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePicturesLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePicturesLog.cs index d17297d..55f1fcb 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePicturesLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePicturesLog.cs @@ -1,8 +1,9 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record UpdatePicturesLog( - Guid SubjectId, - string? PreviousOriginalName, - string? PreviousSmallName, - string? PreviousMediumName, - string? PreviousLargeName); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record UpdatePicturesLog( + Guid SubjectId, + string? PreviousOriginalName, + string? PreviousSmallName, + string? PreviousMediumName, + string? PreviousLargeName); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs index e883935..52064ab 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs @@ -1,6 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record UpdateTrackAudioLog( - Guid TrackId, - string? PreviousAudioFolderName, - double PreviousDurationSeconds); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record UpdateTrackAudioLog( + Guid TrackId, + string? PreviousAudioFolderName, + double PreviousDurationSeconds); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs index 7cf63ff..ab95439 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs @@ -1,3 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record UploadFileToBucketLog(string DestinationBucket, string DestinationKey); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record UploadFileToBucketLog(string DestinationBucket, string DestinationKey); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs b/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs index a10b3d4..e9d300f 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs @@ -1,11 +1,12 @@ using MassTransit; using Musify.Application.Contracts; -namespace Musify.Infrastructure.MassTransit; - -public class MassTransitEventBus(IPublishEndpoint publishEndpoint) - : IEventBus +namespace Musify.Infrastructure.MassTransit { - public Task PublishAsync(T message, CancellationToken cancellationToken) where T : class => - publishEndpoint.Publish(message, cancellationToken); + public class MassTransitEventBus(IPublishEndpoint publishEndpoint) + : IEventBus + { + public Task PublishAsync(T message, CancellationToken cancellationToken) where T : class => + publishEndpoint.Publish(message, cancellationToken); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs index c6ddefd..25cbd26 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs @@ -1,62 +1,63 @@ -namespace Musify.Infrastructure.MassTransit.RoutingSlip; - -internal static class ActivityNames +namespace Musify.Infrastructure.MassTransit.RoutingSlip { - internal const string MarkTrackAsRemoving = "MarkTrackAsRemoving"; - internal const string MarkTrackAsFailed = "MarkTrackAsFailed"; - internal const string DeleteTrackFromDb = "DeleteTrackFromDb"; - internal const string MarkPlayListAsRemoving = "MarkPlayListAsRemoving"; - internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed"; - internal const string DeletePlayListFromDb = "DeletePlayListFromDb"; - internal const string MarkAlbumAsFailed = "MarkAlbumAsFailed"; - internal const string MarkAlbumAsRemoving = "MarkAlbumAsRemoving"; - internal const string DeleteAlbumFromDb = "DeleteAlbumFromDb"; + internal static class ActivityNames + { + internal const string MarkTrackAsRemoving = "MarkTrackAsRemoving"; + internal const string MarkTrackAsFailed = "MarkTrackAsFailed"; + internal const string DeleteTrackFromDb = "DeleteTrackFromDb"; + internal const string MarkPlayListAsRemoving = "MarkPlayListAsRemoving"; + internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed"; + internal const string DeletePlayListFromDb = "DeletePlayListFromDb"; + internal const string MarkAlbumAsFailed = "MarkAlbumAsFailed"; + internal const string MarkAlbumAsRemoving = "MarkAlbumAsRemoving"; + internal const string DeleteAlbumFromDb = "DeleteAlbumFromDb"; - internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths"; - internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; + internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths"; + internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; - internal const string DownloadFile = "DownloadFile"; + internal const string DownloadFile = "DownloadFile"; - internal const string TranscodeAudio = "TranscodeAudio"; + internal const string TranscodeAudio = "TranscodeAudio"; - internal const string ResizeSmall = "ResizeSmall"; - internal const string ResizeMedium = "ResizeMedium"; - internal const string ResizeLarge = "ResizeLarge"; + internal const string ResizeSmall = "ResizeSmall"; + internal const string ResizeMedium = "ResizeMedium"; + internal const string ResizeLarge = "ResizeLarge"; - internal const string UploadSmall = "UploadSmall"; - internal const string UploadMedium = "UploadMedium"; - internal const string UploadLarge = "UploadLarge"; + internal const string UploadSmall = "UploadSmall"; + internal const string UploadMedium = "UploadMedium"; + internal const string UploadLarge = "UploadLarge"; - internal const string TransferFiles = "TransferFiles"; + internal const string TransferFiles = "TransferFiles"; - internal const string UpdateTrackPicture = "UpdateTrackPicture"; - internal const string UpdatePlayListPicture = "UpdatePlayListPicture"; - internal const string UpdateAlbumPicture = "UpdateAlbumPicture"; - internal const string UpdateTrackAudio = "UpdateTrackAudio"; + internal const string UpdateTrackPicture = "UpdateTrackPicture"; + internal const string UpdatePlayListPicture = "UpdatePlayListPicture"; + internal const string UpdateAlbumPicture = "UpdateAlbumPicture"; + internal const string UpdateTrackAudio = "UpdateTrackAudio"; - internal const string CopyPictureToFinal = "CopyPictureToFinal"; - internal const string CopyAudioToFinal = "CopyAudioToFinal"; + internal const string CopyPictureToFinal = "CopyPictureToFinal"; + internal const string CopyAudioToFinal = "CopyAudioToFinal"; - internal const string ConsumeUploadIntents = "ConsumeUploadIntents"; + internal const string ConsumeUploadIntents = "ConsumeUploadIntents"; - internal const string PublishTrackProcessingEvents = "PublishTrackProcessingEvents"; - internal const string PublishPlayListPictureProcessingEvent = "PublishPlayListPictureProcessingEvent"; - internal const string PublishAlbumPictureProcessingEvent = "PublishAlbumPictureProcessingEvent"; + internal const string PublishTrackProcessingEvents = "PublishTrackProcessingEvents"; + internal const string PublishPlayListPictureProcessingEvent = "PublishPlayListPictureProcessingEvent"; + internal const string PublishAlbumPictureProcessingEvent = "PublishAlbumPictureProcessingEvent"; - internal const string RemoveTrackOriginalPicture = "RemoveTrackOriginalPicture"; - internal const string RemoveTrackSmallPicture = "RemoveTrackSmallPicture"; - internal const string RemoveTrackMediumPicture = "RemoveTrackMediumPicture"; - internal const string RemoveTrackLargePicture = "RemoveTrackLargePicture"; - internal const string RemoveTrackOriginalAudio = "RemoveTrackOriginalAudio"; - internal const string RemoveTrackProcessedAudio = "RemoveTrackProcessedAudio"; + internal const string RemoveTrackOriginalPicture = "RemoveTrackOriginalPicture"; + internal const string RemoveTrackSmallPicture = "RemoveTrackSmallPicture"; + internal const string RemoveTrackMediumPicture = "RemoveTrackMediumPicture"; + internal const string RemoveTrackLargePicture = "RemoveTrackLargePicture"; + internal const string RemoveTrackOriginalAudio = "RemoveTrackOriginalAudio"; + internal const string RemoveTrackProcessedAudio = "RemoveTrackProcessedAudio"; - internal const string RemovePlayListOriginalPicture = "RemovePlayListOriginalPicture"; - internal const string RemovePlayListSmallPicture = "RemovePlayListSmallPicture"; - internal const string RemovePlayListMediumPicture = "RemovePlayListMediumPicture"; - internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture"; + internal const string RemovePlayListOriginalPicture = "RemovePlayListOriginalPicture"; + internal const string RemovePlayListSmallPicture = "RemovePlayListSmallPicture"; + internal const string RemovePlayListMediumPicture = "RemovePlayListMediumPicture"; + internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture"; - internal const string RemoveAlbumOriginalPicture = "RemoveAlbumOriginalPicture"; - internal const string RemoveAlbumSmallPicture = "RemoveAlbumSmallPicture"; - internal const string RemoveAlbumMediumPicture = "RemoveAlbumMediumPicture"; - internal const string RemoveAlbumLargePicture = "RemoveAlbumLargePicture"; + internal const string RemoveAlbumOriginalPicture = "RemoveAlbumOriginalPicture"; + internal const string RemoveAlbumSmallPicture = "RemoveAlbumSmallPicture"; + internal const string RemoveAlbumMediumPicture = "RemoveAlbumMediumPicture"; + internal const string RemoveAlbumLargePicture = "RemoveAlbumLargePicture"; + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs index b3f1e07..d4e3be2 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs @@ -5,40 +5,41 @@ using Musify.Infrastructure.MassTransit.Activities.Audio; using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; - -public class AudioWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { - public RoutingSlipBuilder Build(UpdateTrackAudioEvent message, Guid? correlationId) => - RoutingSlips.Create(correlationId) - .AddStep( - ActivityNames.GenerateAudioWorkflowPaths, - GenerateAudioWorkflowPathsActivity.ExecuteEndpointName, - new GenerateAudioWorkflowPathsArguments( - message.TrackId, workerConfiguration.Routes.TemporaryFilesDirectory, message.SourceKey)) - .AddStep( - ActivityNames.DownloadFile, - DownloadFileFromBucketActivity.ExecuteEndpointName, - new DownloadFileFromBucketArguments( - message.SourceBucket, message.SourceKey, RoutingSlipVariableNames.Audio.SourceFilePath)) - .AddStep( - ActivityNames.TranscodeAudio, - TranscodeAudioActivity.ExecuteEndpointName, - new TranscodeAudioArguments( - message.TrackId, - RoutingSlipVariableNames.Audio.SourceFilePath, - RoutingSlipVariableNames.Workflow.TemporalDirectory)) - .AddStep( - ActivityNames.TransferFiles, - TransferFilesToBucketActivity.ExecuteEndpointName, - new TransferFilesToBucketArguments( - message.DestinationBucket, - RoutingSlipVariableNames.Audio.TranscodedDirectory, - message.DestinationFolderKey)) - .AddStep( - ActivityNames.UpdateTrackAudio, - UpdateTrackAudioActivity.ExecuteEndpointName, - new UpdateTrackAudioArguments( - message.TrackId, message.DestinationFolderKey, RoutingSlipVariableNames.Audio.DurationSeconds)) - .TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackAudio); + public class AudioWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) + { + public RoutingSlipBuilder Build(UpdateTrackAudioEvent message, Guid? correlationId) => + RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.GenerateAudioWorkflowPaths, + GenerateAudioWorkflowPathsActivity.ExecuteEndpointName, + new GenerateAudioWorkflowPathsArguments( + message.TrackId, workerConfiguration.Routes.TemporaryFilesDirectory, message.SourceKey)) + .AddStep( + ActivityNames.DownloadFile, + DownloadFileFromBucketActivity.ExecuteEndpointName, + new DownloadFileFromBucketArguments( + message.SourceBucket, message.SourceKey, RoutingSlipVariableNames.Audio.SourceFilePath)) + .AddStep( + ActivityNames.TranscodeAudio, + TranscodeAudioActivity.ExecuteEndpointName, + new TranscodeAudioArguments( + message.TrackId, + RoutingSlipVariableNames.Audio.SourceFilePath, + RoutingSlipVariableNames.Workflow.TemporalDirectory)) + .AddStep( + ActivityNames.TransferFiles, + TransferFilesToBucketActivity.ExecuteEndpointName, + new TransferFilesToBucketArguments( + message.DestinationBucket, + RoutingSlipVariableNames.Audio.TranscodedDirectory, + message.DestinationFolderKey)) + .AddStep( + ActivityNames.UpdateTrackAudio, + UpdateTrackAudioActivity.ExecuteEndpointName, + new UpdateTrackAudioArguments( + message.TrackId, message.DestinationFolderKey, RoutingSlipVariableNames.Audio.DurationSeconds)) + .TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackAudio); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs index d8b1f50..f8ba5ab 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs @@ -5,41 +5,42 @@ using Musify.Infrastructure.MassTransit.Activities.Tracks; using Musify.Infrastructure.MassTransit.Activities.UploadIntents; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; - -public class CreateTrackRoutingSlipBuilder +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { - public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId) + public class CreateTrackRoutingSlipBuilder { - var builder = RoutingSlips.Create(correlationId) - .AddStep( - ActivityNames.CopyPictureToFinal, - CopyFileInBucketActivity.ExecuteEndpointName, - new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey, message.Bucket, message.PictureDestinationKey)) - .AddStep( - ActivityNames.CopyAudioToFinal, - CopyFileInBucketActivity.ExecuteEndpointName, - new CopyFileInBucketArguments(message.Bucket, message.AudioSourceKey, message.Bucket, message.AudioDestinationKey)) - .AddStep( - ActivityNames.ConsumeUploadIntents, - ConsumeUploadIntentsActivity.ExecuteEndpointName, - new ConsumeUploadIntentsArguments([message.PictureIntentId, message.AudioIntentId])) - .AddStep( - ActivityNames.PublishTrackProcessingEvents, - PublishTrackProcessingEventsActivity.ExecuteEndpointName, - new PublishTrackProcessingEventsArguments( - message.TrackId, - message.Bucket, - message.PictureDestinationKey, - message.AudioDestinationKey, - message.AudioProcessedFolderKey, - message.Sizes)) - .TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackCreation); + public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.CopyPictureToFinal, + CopyFileInBucketActivity.ExecuteEndpointName, + new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey, message.Bucket, message.PictureDestinationKey)) + .AddStep( + ActivityNames.CopyAudioToFinal, + CopyFileInBucketActivity.ExecuteEndpointName, + new CopyFileInBucketArguments(message.Bucket, message.AudioSourceKey, message.Bucket, message.AudioDestinationKey)) + .AddStep( + ActivityNames.ConsumeUploadIntents, + ConsumeUploadIntentsActivity.ExecuteEndpointName, + new ConsumeUploadIntentsArguments([message.PictureIntentId, message.AudioIntentId])) + .AddStep( + ActivityNames.PublishTrackProcessingEvents, + PublishTrackProcessingEventsActivity.ExecuteEndpointName, + new PublishTrackProcessingEventsArguments( + message.TrackId, + message.Bucket, + message.PictureDestinationKey, + message.AudioDestinationKey, + message.AudioProcessedFolderKey, + message.Sizes)) + .TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackCreation); - builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, message.Bucket); - builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey); - builder.AddVariable(RoutingSlipVariableNames.Workflow.AudioKey, message.AudioDestinationKey); + builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, message.Bucket); + builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey); + builder.AddVariable(RoutingSlipVariableNames.Workflow.AudioKey, message.AudioDestinationKey); - return builder; + return builder; + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs index 694c7ff..6e5f6e3 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs @@ -6,130 +6,131 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Activities.LifeCycle; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; - -/// Marks an entity as removing, deletes its files from the bucket and finally deletes its row. -public class DeleteRoutingSlipBuilder( - IDatabase database, - TrackConfiguration trackConfiguration, - AlbumConfiguration albumConfiguration, - PlayListConfiguration playListConfiguration, - ApplicationStorageConfiguration storageConfiguration) +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { - private sealed record PictureStepNames(string Original, string Small, string Medium, string Large); - - private static readonly PictureStepNames AlbumSteps = new( - ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.RemoveAlbumSmallPicture, - ActivityNames.RemoveAlbumMediumPicture, ActivityNames.RemoveAlbumLargePicture); - - private static readonly PictureStepNames PlayListSteps = new( - ActivityNames.RemovePlayListOriginalPicture, ActivityNames.RemovePlayListSmallPicture, - ActivityNames.RemovePlayListMediumPicture, ActivityNames.RemovePlayListLargePicture); - - public async Task BuildTrackAsync( - Guid trackId, long userId, Guid? correlationId, CancellationToken cancellationToken) + /// Marks an entity as removing, deletes its files from the bucket and finally deletes its row. + public class DeleteRoutingSlipBuilder( + IDatabase database, + TrackConfiguration trackConfiguration, + AlbumConfiguration albumConfiguration, + PlayListConfiguration playListConfiguration, + ApplicationStorageConfiguration storageConfiguration) { - var builder = RoutingSlips.Create(correlationId) - .AddStep( - ActivityNames.MarkTrackAsRemoving, - MarkTrackLifeCycleActivity.ExecuteEndpointName, - new MarkLifeCycleArguments(trackId, LifeCycleStatus.Removing)); + private sealed record PictureStepNames(string Original, string Small, string Medium, string Large); - var track = await database.Tracks.AsNoTracking().SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); - if (track != null) + private static readonly PictureStepNames AlbumSteps = new( + ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.RemoveAlbumSmallPicture, + ActivityNames.RemoveAlbumMediumPicture, ActivityNames.RemoveAlbumLargePicture); + + private static readonly PictureStepNames PlayListSteps = new( + ActivityNames.RemovePlayListOriginalPicture, ActivityNames.RemovePlayListSmallPicture, + ActivityNames.RemovePlayListMediumPicture, ActivityNames.RemovePlayListLargePicture); + + public async Task BuildTrackAsync( + Guid trackId, long userId, Guid? correlationId, CancellationToken cancellationToken) { - var bucket = storageConfiguration.Bucket; - var routes = trackConfiguration.Routes; + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.MarkTrackAsRemoving, + MarkTrackLifeCycleActivity.ExecuteEndpointName, + new MarkLifeCycleArguments(trackId, LifeCycleStatus.Removing)); - if (!string.IsNullOrEmpty(track.Pictures.OriginalName)) + var track = await database.Tracks.AsNoTracking().SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); + if (track != null) { - builder.AddRemoveFile( - ActivityNames.RemoveTrackOriginalPicture, bucket, - routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName)); + var bucket = storageConfiguration.Bucket; + var routes = trackConfiguration.Routes; + + if (!string.IsNullOrEmpty(track.Pictures.OriginalName)) + { + builder.AddRemoveFile( + ActivityNames.RemoveTrackOriginalPicture, bucket, + routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName)); + } + + // Unprocessed tracks still point at the shared preset pictures, which must never be deleted. + if (track.Pictures.IsProcessed) + { + builder.AddRemoveFile(ActivityNames.RemoveTrackSmallPicture, bucket, routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName)); + builder.AddRemoveFile(ActivityNames.RemoveTrackMediumPicture, bucket, routes.BuildPicturePath(PictureSize.Medium, track.Pictures.MediumName)); + builder.AddRemoveFile(ActivityNames.RemoveTrackLargePicture, bucket, routes.BuildPicturePath(PictureSize.Large, track.Pictures.LargeName)); + } + + if (!string.IsNullOrEmpty(track.Audio.OriginalName)) + { + builder.AddRemoveFile( + ActivityNames.RemoveTrackOriginalAudio, bucket, + routes.BuildOriginalAudioPath(userId, track.Audio.OriginalName)); + } + + if (track.Audio.IsProcessed) + { + builder.AddRemoveFile( + ActivityNames.RemoveTrackProcessedAudio, bucket, + routes.BuildProcessedAudioPath(track.Audio.FolderName)); + } } - // Unprocessed tracks still point at the shared preset pictures, which must never be deleted. - if (track.Pictures.IsProcessed) - { - builder.AddRemoveFile(ActivityNames.RemoveTrackSmallPicture, bucket, routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName)); - builder.AddRemoveFile(ActivityNames.RemoveTrackMediumPicture, bucket, routes.BuildPicturePath(PictureSize.Medium, track.Pictures.MediumName)); - builder.AddRemoveFile(ActivityNames.RemoveTrackLargePicture, bucket, routes.BuildPicturePath(PictureSize.Large, track.Pictures.LargeName)); - } - - if (!string.IsNullOrEmpty(track.Audio.OriginalName)) - { - builder.AddRemoveFile( - ActivityNames.RemoveTrackOriginalAudio, bucket, - routes.BuildOriginalAudioPath(userId, track.Audio.OriginalName)); - } - - if (track.Audio.IsProcessed) - { - builder.AddRemoveFile( - ActivityNames.RemoveTrackProcessedAudio, bucket, - routes.BuildProcessedAudioPath(track.Audio.FolderName)); - } + return builder.AddStep( + ActivityNames.DeleteTrackFromDb, DeleteTrackActivity.ExecuteEndpointName, new DeleteEntityArguments(trackId)); } - return builder.AddStep( - ActivityNames.DeleteTrackFromDb, DeleteTrackActivity.ExecuteEndpointName, new DeleteEntityArguments(trackId)); - } + public async Task BuildAlbumAsync( + Guid albumId, long userId, Guid? correlationId, CancellationToken cancellationToken) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.MarkAlbumAsRemoving, + MarkAlbumLifeCycleActivity.ExecuteEndpointName, + new MarkLifeCycleArguments(albumId, LifeCycleStatus.Removing)); - public async Task BuildAlbumAsync( - Guid albumId, long userId, Guid? correlationId, CancellationToken cancellationToken) - { - var builder = RoutingSlips.Create(correlationId) - .AddStep( - ActivityNames.MarkAlbumAsRemoving, - MarkAlbumLifeCycleActivity.ExecuteEndpointName, - new MarkLifeCycleArguments(albumId, LifeCycleStatus.Removing)); + var pictures = await database.Albums.AsNoTracking() + .Where(a => a.Id == albumId) + .Select(a => a.Pictures) + .SingleOrDefaultAsync(cancellationToken); - var pictures = await database.Albums.AsNoTracking() - .Where(a => a.Id == albumId) - .Select(a => a.Pictures) - .SingleOrDefaultAsync(cancellationToken); + AddPictureRemovals(builder, AlbumSteps, albumConfiguration.Routes, userId, pictures); - AddPictureRemovals(builder, AlbumSteps, albumConfiguration.Routes, userId, pictures); + return builder.AddStep( + ActivityNames.DeleteAlbumFromDb, DeleteAlbumActivity.ExecuteEndpointName, new DeleteEntityArguments(albumId)); + } - return builder.AddStep( - ActivityNames.DeleteAlbumFromDb, DeleteAlbumActivity.ExecuteEndpointName, new DeleteEntityArguments(albumId)); - } + public async Task BuildPlayListAsync( + Guid playListId, long userId, Guid? correlationId, CancellationToken cancellationToken) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.MarkPlayListAsRemoving, + MarkPlayListLifeCycleActivity.ExecuteEndpointName, + new MarkLifeCycleArguments(playListId, LifeCycleStatus.Removing)); - public async Task BuildPlayListAsync( - Guid playListId, long userId, Guid? correlationId, CancellationToken cancellationToken) - { - var builder = RoutingSlips.Create(correlationId) - .AddStep( - ActivityNames.MarkPlayListAsRemoving, - MarkPlayListLifeCycleActivity.ExecuteEndpointName, - new MarkLifeCycleArguments(playListId, LifeCycleStatus.Removing)); + var pictures = await database.PlayLists.AsNoTracking() + .Where(p => p.Id == playListId) + .Select(p => p.Pictures) + .SingleOrDefaultAsync(cancellationToken); - var pictures = await database.PlayLists.AsNoTracking() - .Where(p => p.Id == playListId) - .Select(p => p.Pictures) - .SingleOrDefaultAsync(cancellationToken); + AddPictureRemovals(builder, PlayListSteps, playListConfiguration.Routes, userId, pictures); - AddPictureRemovals(builder, PlayListSteps, playListConfiguration.Routes, userId, pictures); + return builder.AddStep( + ActivityNames.DeletePlayListFromDb, DeletePlayListActivity.ExecuteEndpointName, new DeleteEntityArguments(playListId)); + } - return builder.AddStep( - ActivityNames.DeletePlayListFromDb, DeletePlayListActivity.ExecuteEndpointName, new DeleteEntityArguments(playListId)); - } + private void AddPictureRemovals( + RoutingSlipBuilder builder, PictureStepNames steps, PictureRoutes routes, long userId, EntityPictures? pictures) + { + if (pictures == null) + return; - private void AddPictureRemovals( - RoutingSlipBuilder builder, PictureStepNames steps, PictureRoutes routes, long userId, EntityPictures? pictures) - { - if (pictures == null) - return; + var bucket = storageConfiguration.Bucket; - var bucket = storageConfiguration.Bucket; - - if (pictures.OriginalName is { } original) - builder.AddRemoveFile(steps.Original, bucket, routes.BuildOriginalPicturePath(userId, original)); - if (pictures.SmallName is { } small) - builder.AddRemoveFile(steps.Small, bucket, routes.BuildPicturePath(PictureSize.Small, small)); - if (pictures.MediumName is { } medium) - builder.AddRemoveFile(steps.Medium, bucket, routes.BuildPicturePath(PictureSize.Medium, medium)); - if (pictures.LargeName is { } large) - builder.AddRemoveFile(steps.Large, bucket, routes.BuildPicturePath(PictureSize.Large, large)); + if (pictures.OriginalName is { } original) + builder.AddRemoveFile(steps.Original, bucket, routes.BuildOriginalPicturePath(userId, original)); + if (pictures.SmallName is { } small) + builder.AddRemoveFile(steps.Small, bucket, routes.BuildPicturePath(PictureSize.Small, small)); + if (pictures.MediumName is { } medium) + builder.AddRemoveFile(steps.Medium, bucket, routes.BuildPicturePath(PictureSize.Medium, medium)); + if (pictures.LargeName is { } large) + builder.AddRemoveFile(steps.Large, bucket, routes.BuildPicturePath(PictureSize.Large, large)); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureSourceRoutingSlipBuilder.cs index 2069f14..6395130 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureSourceRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureSourceRoutingSlipBuilder.cs @@ -6,69 +6,70 @@ using Musify.Infrastructure.MassTransit.Activities.PlayLists; using Musify.Infrastructure.MassTransit.Activities.UploadIntents; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; - -/// -/// Moves an uploaded album/playlist picture to its final key, consumes the upload intent and hands over to the picture workflow. -/// Creation faults mark the whole entity as failed; update faults only report the picture as failed. -/// -public class PictureSourceRoutingSlipBuilder +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { - public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) => - BuildSlip( - message.AlbumId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, - correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumCreation, - ActivityNames.PublishAlbumPictureProcessingEvent, PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName, - new PublishAlbumPictureProcessingEventArguments(message.AlbumId, message.Bucket, message.PictureDestinationKey, message.Sizes)); - - public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId) => - BuildSlip( - message.AlbumId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, - correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumPicture, - ActivityNames.PublishAlbumPictureProcessingEvent, PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName, - new PublishAlbumPictureProcessingEventArguments(message.AlbumId, message.Bucket, message.PictureDestinationKey, message.Sizes)); - - public RoutingSlipBuilder Build(CreatePlayListResourcesEvent message, Guid? correlationId) => - BuildSlip( - message.PlayListId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, - correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListCreation, - ActivityNames.PublishPlayListPictureProcessingEvent, PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName, - new PublishPlayListPictureProcessingEventArguments(message.PlayListId, message.Bucket, message.PictureDestinationKey, message.Sizes)); - - public RoutingSlipBuilder Build(UpdatePlayListPictureSourceEvent message, Guid? correlationId) => - BuildSlip( - message.PlayListId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, - correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, - ActivityNames.PublishPlayListPictureProcessingEvent, PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName, - new PublishPlayListPictureProcessingEventArguments(message.PlayListId, message.Bucket, message.PictureDestinationKey, message.Sizes)); - - private static RoutingSlipBuilder BuildSlip( - Guid subjectId, - Guid pictureIntentId, - string bucket, - string pictureSourceKey, - string pictureDestinationKey, - Guid? correlationId, - string processKind, - string publishStepName, - string publishEndpointName, - object publishArguments) + /// + /// Moves an uploaded album/playlist picture to its final key, consumes the upload intent and hands over to the picture workflow. + /// Creation faults mark the whole entity as failed; update faults only report the picture as failed. + /// + public class PictureSourceRoutingSlipBuilder { - var builder = RoutingSlips.Create(correlationId) - .AddStep( - ActivityNames.CopyPictureToFinal, - CopyFileInBucketActivity.ExecuteEndpointName, - new CopyFileInBucketArguments(bucket, pictureSourceKey, bucket, pictureDestinationKey)) - .AddStep( - ActivityNames.ConsumeUploadIntents, - ConsumeUploadIntentsActivity.ExecuteEndpointName, - new ConsumeUploadIntentsArguments([pictureIntentId])) - .AddStep(publishStepName, publishEndpointName, publishArguments) - .TrackFaults(subjectId, processKind); + public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) => + BuildSlip( + message.AlbumId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, + correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumCreation, + ActivityNames.PublishAlbumPictureProcessingEvent, PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName, + new PublishAlbumPictureProcessingEventArguments(message.AlbumId, message.Bucket, message.PictureDestinationKey, message.Sizes)); - builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, bucket); - builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, pictureDestinationKey); + public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId) => + BuildSlip( + message.AlbumId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, + correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumPicture, + ActivityNames.PublishAlbumPictureProcessingEvent, PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName, + new PublishAlbumPictureProcessingEventArguments(message.AlbumId, message.Bucket, message.PictureDestinationKey, message.Sizes)); - return builder; + public RoutingSlipBuilder Build(CreatePlayListResourcesEvent message, Guid? correlationId) => + BuildSlip( + message.PlayListId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, + correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListCreation, + ActivityNames.PublishPlayListPictureProcessingEvent, PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName, + new PublishPlayListPictureProcessingEventArguments(message.PlayListId, message.Bucket, message.PictureDestinationKey, message.Sizes)); + + public RoutingSlipBuilder Build(UpdatePlayListPictureSourceEvent message, Guid? correlationId) => + BuildSlip( + message.PlayListId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, + correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, + ActivityNames.PublishPlayListPictureProcessingEvent, PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName, + new PublishPlayListPictureProcessingEventArguments(message.PlayListId, message.Bucket, message.PictureDestinationKey, message.Sizes)); + + private static RoutingSlipBuilder BuildSlip( + Guid subjectId, + Guid pictureIntentId, + string bucket, + string pictureSourceKey, + string pictureDestinationKey, + Guid? correlationId, + string processKind, + string publishStepName, + string publishEndpointName, + object publishArguments) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.CopyPictureToFinal, + CopyFileInBucketActivity.ExecuteEndpointName, + new CopyFileInBucketArguments(bucket, pictureSourceKey, bucket, pictureDestinationKey)) + .AddStep( + ActivityNames.ConsumeUploadIntents, + ConsumeUploadIntentsActivity.ExecuteEndpointName, + new ConsumeUploadIntentsArguments([pictureIntentId])) + .AddStep(publishStepName, publishEndpointName, publishArguments) + .TrackFaults(subjectId, processKind); + + builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, bucket); + builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, pictureDestinationKey); + + return builder; + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs index bf81fe6..07cfcda 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs @@ -6,81 +6,82 @@ using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Activities.Pictures; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; - -/// Downloads a picture, resizes it to the three sizes, uploads them and records the result on the owner. -public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { - public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) => - BuildPictureWorkflow( - message.TrackId, message.Bucket, message.SourceKey, message.Sizes, correlationId, - ActivityNames.UpdateTrackPicture, UpdateTrackPictureActivity.ExecuteEndpointName, - RoutingSlipVariableNames.ProcessKinds.TrackPicture); - - public RoutingSlipBuilder Build(UpdatePlayListPictureEvent message, Guid? correlationId) => - BuildPictureWorkflow( - message.PlayListId, message.Bucket, message.SourceKey, message.Sizes, correlationId, - ActivityNames.UpdatePlayListPicture, UpdatePlayListPictureActivity.ExecuteEndpointName, - RoutingSlipVariableNames.ProcessKinds.PlayListPicture); - - public RoutingSlipBuilder Build(UpdateAlbumPictureEvent message, Guid? correlationId) => - BuildPictureWorkflow( - message.AlbumId, message.Bucket, message.SourceKey, message.Sizes, correlationId, - ActivityNames.UpdateAlbumPicture, UpdateAlbumPictureActivity.ExecuteEndpointName, - RoutingSlipVariableNames.ProcessKinds.AlbumPicture); - - private RoutingSlipBuilder BuildPictureWorkflow( - Guid subjectId, - string bucket, - string sourceKey, - ImageSizes sizes, - Guid? correlationId, - string updateStepName, - string updateEndpointName, - string processKind) + /// Downloads a picture, resizes it to the three sizes, uploads them and records the result on the owner. + public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) { - var builder = RoutingSlips.Create(correlationId) - .AddStep( - ActivityNames.GeneratePictureWorkflowPaths, - GeneratePictureWorkflowPathsActivity.ExecuteEndpointName, - new GeneratePictureWorkflowPathsArguments(workerConfiguration.Routes.TemporaryFilesDirectory, sourceKey)) - .AddStep( - ActivityNames.DownloadFile, - DownloadFileFromBucketActivity.ExecuteEndpointName, - new DownloadFileFromBucketArguments(bucket, sourceKey, RoutingSlipVariableNames.Picture.OriginalFilePath)); + public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) => + BuildPictureWorkflow( + message.TrackId, message.Bucket, message.SourceKey, message.Sizes, correlationId, + ActivityNames.UpdateTrackPicture, UpdateTrackPictureActivity.ExecuteEndpointName, + RoutingSlipVariableNames.ProcessKinds.TrackPicture); - AddResize(builder, ActivityNames.ResizeSmall, ActivityNames.UploadSmall, bucket, RoutingSlipVariableNames.Picture.SmallResizedFilePath, sizes.Small); - AddResize(builder, ActivityNames.ResizeMedium, ActivityNames.UploadMedium, bucket, RoutingSlipVariableNames.Picture.MediumResizedFilePath, sizes.Medium); - AddResize(builder, ActivityNames.ResizeLarge, ActivityNames.UploadLarge, bucket, RoutingSlipVariableNames.Picture.LargeResizedFilePath, sizes.Large); + public RoutingSlipBuilder Build(UpdatePlayListPictureEvent message, Guid? correlationId) => + BuildPictureWorkflow( + message.PlayListId, message.Bucket, message.SourceKey, message.Sizes, correlationId, + ActivityNames.UpdatePlayListPicture, UpdatePlayListPictureActivity.ExecuteEndpointName, + RoutingSlipVariableNames.ProcessKinds.PlayListPicture); - return builder - .AddStep( - updateStepName, - updateEndpointName, - new UpdatePicturesArguments( - subjectId, - sourceKey, - RoutingSlipVariableNames.Picture.SmallResizedFilePath, - RoutingSlipVariableNames.Picture.MediumResizedFilePath, - RoutingSlipVariableNames.Picture.LargeResizedFilePath)) - .TrackFaults(subjectId, processKind); + public RoutingSlipBuilder Build(UpdateAlbumPictureEvent message, Guid? correlationId) => + BuildPictureWorkflow( + message.AlbumId, message.Bucket, message.SourceKey, message.Sizes, correlationId, + ActivityNames.UpdateAlbumPicture, UpdateAlbumPictureActivity.ExecuteEndpointName, + RoutingSlipVariableNames.ProcessKinds.AlbumPicture); + + private RoutingSlipBuilder BuildPictureWorkflow( + Guid subjectId, + string bucket, + string sourceKey, + ImageSizes sizes, + Guid? correlationId, + string updateStepName, + string updateEndpointName, + string processKind) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.GeneratePictureWorkflowPaths, + GeneratePictureWorkflowPathsActivity.ExecuteEndpointName, + new GeneratePictureWorkflowPathsArguments(workerConfiguration.Routes.TemporaryFilesDirectory, sourceKey)) + .AddStep( + ActivityNames.DownloadFile, + DownloadFileFromBucketActivity.ExecuteEndpointName, + new DownloadFileFromBucketArguments(bucket, sourceKey, RoutingSlipVariableNames.Picture.OriginalFilePath)); + + AddResize(builder, ActivityNames.ResizeSmall, ActivityNames.UploadSmall, bucket, RoutingSlipVariableNames.Picture.SmallResizedFilePath, sizes.Small); + AddResize(builder, ActivityNames.ResizeMedium, ActivityNames.UploadMedium, bucket, RoutingSlipVariableNames.Picture.MediumResizedFilePath, sizes.Medium); + AddResize(builder, ActivityNames.ResizeLarge, ActivityNames.UploadLarge, bucket, RoutingSlipVariableNames.Picture.LargeResizedFilePath, sizes.Large); + + return builder + .AddStep( + updateStepName, + updateEndpointName, + new UpdatePicturesArguments( + subjectId, + sourceKey, + RoutingSlipVariableNames.Picture.SmallResizedFilePath, + RoutingSlipVariableNames.Picture.MediumResizedFilePath, + RoutingSlipVariableNames.Picture.LargeResizedFilePath)) + .TrackFaults(subjectId, processKind); + } + + private static void AddResize( + RoutingSlipBuilder builder, + string resizeStepName, + string uploadStepName, + string bucket, + string resizedFileVariable, + ImageSize size) => + builder + .AddStep( + resizeStepName, + ResizePictureActivity.ExecuteEndpointName, + new ResizePictureLocalArguments( + RoutingSlipVariableNames.Picture.OriginalFilePath, resizedFileVariable, size.Width, size.Height)) + .AddStep( + uploadStepName, + UploadFileToBucketActivity.ExecuteEndpointName, + new UploadFileToBucketArguments(resizedFileVariable, bucket, size.SavePath)); } - - private static void AddResize( - RoutingSlipBuilder builder, - string resizeStepName, - string uploadStepName, - string bucket, - string resizedFileVariable, - ImageSize size) => - builder - .AddStep( - resizeStepName, - ResizePictureActivity.ExecuteEndpointName, - new ResizePictureLocalArguments( - RoutingSlipVariableNames.Picture.OriginalFilePath, resizedFileVariable, size.Width, size.Height)) - .AddStep( - uploadStepName, - UploadFileToBucketActivity.ExecuteEndpointName, - new UploadFileToBucketArguments(resizedFileVariable, bucket, size.SavePath)); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs index 393aea4..6290468 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs @@ -1,41 +1,42 @@ -namespace Musify.Infrastructure.MassTransit.RoutingSlip; - -internal static class RoutingSlipVariableNames +namespace Musify.Infrastructure.MassTransit.RoutingSlip { - internal static class Workflow + internal static class RoutingSlipVariableNames { - internal const string TemporalDirectory = "Workflow.TemporalDirectory"; - internal const string CorrelationId = "Workflow.CorrelationId"; - internal const string SubjectId = "Workflow.SubjectId"; - internal const string ProcessKind = "Workflow.ProcessKind"; - internal const string Bucket = "Workflow.Bucket"; - internal const string PictureKey = "Workflow.PictureKey"; - internal const string AudioKey = "Workflow.AudioKey"; - } + internal static class Workflow + { + internal const string TemporalDirectory = "Workflow.TemporalDirectory"; + internal const string CorrelationId = "Workflow.CorrelationId"; + internal const string SubjectId = "Workflow.SubjectId"; + internal const string ProcessKind = "Workflow.ProcessKind"; + internal const string Bucket = "Workflow.Bucket"; + internal const string PictureKey = "Workflow.PictureKey"; + internal const string AudioKey = "Workflow.AudioKey"; + } - internal static class ProcessKinds - { - internal const string TrackPicture = "TrackPicture"; - internal const string TrackAudio = "TrackAudio"; - internal const string PlayListPicture = "PlayListPicture"; - internal const string AlbumPicture = "AlbumPicture"; - internal const string TrackCreation = "TrackCreation"; - internal const string AlbumCreation = "AlbumCreation"; - internal const string PlayListCreation = "PlayListCreation"; - } + internal static class ProcessKinds + { + internal const string TrackPicture = "TrackPicture"; + internal const string TrackAudio = "TrackAudio"; + internal const string PlayListPicture = "PlayListPicture"; + internal const string AlbumPicture = "AlbumPicture"; + internal const string TrackCreation = "TrackCreation"; + internal const string AlbumCreation = "AlbumCreation"; + internal const string PlayListCreation = "PlayListCreation"; + } - internal static class Audio - { - internal const string SourceFilePath = "Audio.SourceFilePath"; - internal const string TranscodedDirectory = "Audio.TranscodedDirectory"; - internal const string DurationSeconds = "Audio.DurationSeconds"; - } + internal static class Audio + { + internal const string SourceFilePath = "Audio.SourceFilePath"; + internal const string TranscodedDirectory = "Audio.TranscodedDirectory"; + internal const string DurationSeconds = "Audio.DurationSeconds"; + } - internal static class Picture - { - internal const string OriginalFilePath = "Picture.OriginalFilePath"; - internal const string SmallResizedFilePath = "Picture.SmallResizedFilePath"; - internal const string MediumResizedFilePath = "Picture.MediumResizedFilePath"; - internal const string LargeResizedFilePath = "Picture.LargeResizedFilePath"; + internal static class Picture + { + internal const string OriginalFilePath = "Picture.OriginalFilePath"; + internal const string SmallResizedFilePath = "Picture.SmallResizedFilePath"; + internal const string MediumResizedFilePath = "Picture.MediumResizedFilePath"; + internal const string LargeResizedFilePath = "Picture.LargeResizedFilePath"; + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs index 7134165..b9cd391 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs @@ -4,45 +4,46 @@ using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Consumers; -namespace Musify.Infrastructure.MassTransit.RoutingSlip; - -/// Shared building blocks for every routing slip in the worker. -internal static class RoutingSlips +namespace Musify.Infrastructure.MassTransit.RoutingSlip { - public static Uri ExecuteUri(string endpointName) => new($"queue:{endpointName}_execute"); - - public static Uri ConsumerUri(string queueName) => new($"queue:{queueName}"); - - /// A new slip that carries the correlation id and always cleans its temporary directory up. - public static RoutingSlipBuilder Create(Guid? correlationId) + /// Shared building blocks for every routing slip in the worker. + internal static class RoutingSlips { - var builder = new RoutingSlipBuilder(NewId.NextGuid()); - builder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); - builder.AddSubscription( - ConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + public static Uri ExecuteUri(string endpointName) => new($"queue:{endpointName}_execute"); - return builder; + public static Uri ConsumerUri(string queueName) => new($"queue:{queueName}"); + + /// A new slip that carries the correlation id and always cleans its temporary directory up. + public static RoutingSlipBuilder Create(Guid? correlationId) + { + var builder = new RoutingSlipBuilder(NewId.NextGuid()); + builder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + builder.AddSubscription( + ConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + return builder; + } + + public static RoutingSlipBuilder AddStep( + this RoutingSlipBuilder builder, string name, string executeEndpointName, object arguments) + { + builder.AddActivity(name, ExecuteUri(executeEndpointName), arguments); + return builder; + } + + /// Publishes a failure event for the subject when the slip faults. + public static RoutingSlipBuilder TrackFaults( + this RoutingSlipBuilder builder, Guid subjectId, string processKind) + { + builder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, subjectId); + builder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind); + builder.AddSubscription(ConsumerUri(ProcessingSlipFaultConsumer.QueueName), RoutingSlipEvents.Faulted); + return builder; + } + + public static RoutingSlipBuilder AddRemoveFile( + this RoutingSlipBuilder builder, string stepName, string bucket, string key) => + builder.AddStep(stepName, RemoveFileFromBucketActivity.ExecuteEndpointName, new RemoveFileFromBucketArguments(bucket, key)); } - - public static RoutingSlipBuilder AddStep( - this RoutingSlipBuilder builder, string name, string executeEndpointName, object arguments) - { - builder.AddActivity(name, ExecuteUri(executeEndpointName), arguments); - return builder; - } - - /// Publishes a failure event for the subject when the slip faults. - public static RoutingSlipBuilder TrackFaults( - this RoutingSlipBuilder builder, Guid subjectId, string processKind) - { - builder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, subjectId); - builder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind); - builder.AddSubscription(ConsumerUri(ProcessingSlipFaultConsumer.QueueName), RoutingSlipEvents.Faulted); - return builder; - } - - public static RoutingSlipBuilder AddRemoveFile( - this RoutingSlipBuilder builder, string stepName, string bucket, string key) => - builder.AddStep(stepName, RemoveFileFromBucketActivity.ExecuteEndpointName, new RemoveFileFromBucketArguments(bucket, key)); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs index 0c2f23e..8229784 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs @@ -1,18 +1,19 @@ using MassTransit; -namespace Musify.Infrastructure.MassTransit.Sagas; - -public class AlbumProcessingState : SagaStateMachineInstance +namespace Musify.Infrastructure.MassTransit.Sagas { - public Guid CorrelationId { get; set; } + public class AlbumProcessingState : SagaStateMachineInstance + { + public Guid CorrelationId { get; set; } - public string CurrentState { get; set; } = null!; + public string CurrentState { get; set; } = null!; - public string? Bucket { get; set; } + public string? Bucket { get; set; } - public string? PictureKey { get; set; } + public string? PictureKey { get; set; } - public DateTime CreatedAt { get; set; } + public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs index 3eaa300..5c66b3e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs @@ -1,58 +1,59 @@ using MassTransit; using Musify.Application.Events; -namespace Musify.Infrastructure.MassTransit.Sagas; - -public sealed class AlbumProcessingStateMachine : MassTransitStateMachine +namespace Musify.Infrastructure.MassTransit.Sagas { - public State Processing { get; private set; } = null!; - public State Failed { get; private set; } = null!; - - public Event ProcessingStarted { get; private set; } = null!; - public Event PictureProcessed { get; private set; } = null!; - public Event PictureFailed { get; private set; } = null!; - - public AlbumProcessingStateMachine() + public sealed class AlbumProcessingStateMachine : MassTransitStateMachine { - InstanceState(state => state.CurrentState); + public State Processing { get; private set; } = null!; + public State Failed { get; private set; } = null!; - Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.AlbumId)); - Event(() => PictureProcessed, config => + public Event ProcessingStarted { get; private set; } = null!; + public Event PictureProcessed { get; private set; } = null!; + public Event PictureFailed { get; private set; } = null!; + + public AlbumProcessingStateMachine() { - config.CorrelateById(context => context.Message.AlbumId); - config.OnMissingInstance(instance => instance.Discard()); - }); - Event(() => PictureFailed, config => - { - config.CorrelateById(context => context.Message.AlbumId); - config.OnMissingInstance(instance => instance.Discard()); - }); + InstanceState(state => state.CurrentState); - Initially( - When(ProcessingStarted) - .Then(context => - { - context.Saga.CreatedAt = DateTime.UtcNow; - context.Saga.UpdatedAt = DateTime.UtcNow; - context.Saga.Bucket = context.Message.Bucket; - context.Saga.PictureKey = context.Message.PictureDestinationKey; - }) - .TransitionTo(Processing)); + Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.AlbumId)); + Event(() => PictureProcessed, config => + { + config.CorrelateById(context => context.Message.AlbumId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => PictureFailed, config => + { + config.CorrelateById(context => context.Message.AlbumId); + config.OnMissingInstance(instance => instance.Discard()); + }); - During(Processing, - When(PictureProcessed).Finalize(), - When(PictureFailed) - .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) - .TransitionTo(Failed) - .Publish(context => new AlbumProcessingFailed( - context.Saga.CorrelationId, - context.Saga.Bucket, - context.Saga.PictureKey))); + Initially( + When(ProcessingStarted) + .Then(context => + { + context.Saga.CreatedAt = DateTime.UtcNow; + context.Saga.UpdatedAt = DateTime.UtcNow; + context.Saga.Bucket = context.Message.Bucket; + context.Saga.PictureKey = context.Message.PictureDestinationKey; + }) + .TransitionTo(Processing)); - During(Failed, - Ignore(PictureProcessed), - Ignore(PictureFailed)); + During(Processing, + When(PictureProcessed).Finalize(), + When(PictureFailed) + .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) + .TransitionTo(Failed) + .Publish(context => new AlbumProcessingFailed( + context.Saga.CorrelationId, + context.Saga.Bucket, + context.Saga.PictureKey))); - SetCompletedWhenFinalized(); + During(Failed, + Ignore(PictureProcessed), + Ignore(PictureFailed)); + + SetCompletedWhenFinalized(); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingState.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingState.cs index 4d3c261..213112b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingState.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingState.cs @@ -1,18 +1,19 @@ using MassTransit; -namespace Musify.Infrastructure.MassTransit.Sagas; - -public class PlayListProcessingState : SagaStateMachineInstance +namespace Musify.Infrastructure.MassTransit.Sagas { - public Guid CorrelationId { get; set; } + public class PlayListProcessingState : SagaStateMachineInstance + { + public Guid CorrelationId { get; set; } - public string CurrentState { get; set; } = null!; + public string CurrentState { get; set; } = null!; - public string? Bucket { get; set; } + public string? Bucket { get; set; } - public string? PictureKey { get; set; } + public string? PictureKey { get; set; } - public DateTime CreatedAt { get; set; } + public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs index 3ab7e6c..3721227 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs @@ -1,58 +1,59 @@ using MassTransit; using Musify.Application.Events; -namespace Musify.Infrastructure.MassTransit.Sagas; - -public sealed class PlayListProcessingStateMachine : MassTransitStateMachine +namespace Musify.Infrastructure.MassTransit.Sagas { - public State Processing { get; private set; } = null!; - public State Failed { get; private set; } = null!; - - public Event ProcessingStarted { get; private set; } = null!; - public Event PictureProcessed { get; private set; } = null!; - public Event PictureFailed { get; private set; } = null!; - - public PlayListProcessingStateMachine() + public sealed class PlayListProcessingStateMachine : MassTransitStateMachine { - InstanceState(state => state.CurrentState); + public State Processing { get; private set; } = null!; + public State Failed { get; private set; } = null!; - Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.PlayListId)); - Event(() => PictureProcessed, config => + public Event ProcessingStarted { get; private set; } = null!; + public Event PictureProcessed { get; private set; } = null!; + public Event PictureFailed { get; private set; } = null!; + + public PlayListProcessingStateMachine() { - config.CorrelateById(context => context.Message.PlayListId); - config.OnMissingInstance(instance => instance.Discard()); - }); - Event(() => PictureFailed, config => - { - config.CorrelateById(context => context.Message.PlayListId); - config.OnMissingInstance(instance => instance.Discard()); - }); + InstanceState(state => state.CurrentState); - Initially( - When(ProcessingStarted) - .Then(context => - { - context.Saga.CreatedAt = DateTime.UtcNow; - context.Saga.UpdatedAt = DateTime.UtcNow; - context.Saga.Bucket = context.Message.Bucket; - context.Saga.PictureKey = context.Message.PictureDestinationKey; - }) - .TransitionTo(Processing)); + Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.PlayListId)); + Event(() => PictureProcessed, config => + { + config.CorrelateById(context => context.Message.PlayListId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => PictureFailed, config => + { + config.CorrelateById(context => context.Message.PlayListId); + config.OnMissingInstance(instance => instance.Discard()); + }); - During(Processing, - When(PictureProcessed).Finalize(), - When(PictureFailed) - .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) - .TransitionTo(Failed) - .Publish(context => new PlayListProcessingFailed( - context.Saga.CorrelationId, - context.Saga.Bucket, - context.Saga.PictureKey))); + Initially( + When(ProcessingStarted) + .Then(context => + { + context.Saga.CreatedAt = DateTime.UtcNow; + context.Saga.UpdatedAt = DateTime.UtcNow; + context.Saga.Bucket = context.Message.Bucket; + context.Saga.PictureKey = context.Message.PictureDestinationKey; + }) + .TransitionTo(Processing)); - During(Failed, - Ignore(PictureProcessed), - Ignore(PictureFailed)); + During(Processing, + When(PictureProcessed).Finalize(), + When(PictureFailed) + .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) + .TransitionTo(Failed) + .Publish(context => new PlayListProcessingFailed( + context.Saga.CorrelationId, + context.Saga.Bucket, + context.Saga.PictureKey))); - SetCompletedWhenFinalized(); + During(Failed, + Ignore(PictureProcessed), + Ignore(PictureFailed)); + + SetCompletedWhenFinalized(); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingState.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingState.cs index 2d0397b..707a3d6 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingState.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingState.cs @@ -1,24 +1,25 @@ using MassTransit; -namespace Musify.Infrastructure.MassTransit.Sagas; - -public class TrackProcessingState : SagaStateMachineInstance +namespace Musify.Infrastructure.MassTransit.Sagas { - public Guid CorrelationId { get; set; } + public class TrackProcessingState : SagaStateMachineInstance + { + public Guid CorrelationId { get; set; } - public string CurrentState { get; set; } = null!; + public string CurrentState { get; set; } = null!; - public bool PictureProcessed { get; set; } + public bool PictureProcessed { get; set; } - public bool AudioProcessed { get; set; } + public bool AudioProcessed { get; set; } - public string? Bucket { get; set; } + public string? Bucket { get; set; } - public string? PictureKey { get; set; } + public string? PictureKey { get; set; } - public string? AudioKey { get; set; } + public string? AudioKey { get; set; } - public DateTime CreatedAt { get; set; } + public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs index 5fdc6de..70c376a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs @@ -1,95 +1,96 @@ using MassTransit; using Musify.Application.Events; -namespace Musify.Infrastructure.MassTransit.Sagas; - -public sealed class TrackProcessingStateMachine : MassTransitStateMachine +namespace Musify.Infrastructure.MassTransit.Sagas { - public State Processing { get; private set; } = null!; - public State Failed { get; private set; } = null!; - - public Event ProcessingStarted { get; private set; } = null!; - public Event PictureProcessed { get; private set; } = null!; - public Event AudioProcessed { get; private set; } = null!; - public Event PictureFailed { get; private set; } = null!; - public Event AudioFailed { get; private set; } = null!; - - public TrackProcessingStateMachine() + public sealed class TrackProcessingStateMachine : MassTransitStateMachine { - InstanceState(state => state.CurrentState); + public State Processing { get; private set; } = null!; + public State Failed { get; private set; } = null!; - Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.TrackId)); - Event(() => PictureProcessed, config => + public Event ProcessingStarted { get; private set; } = null!; + public Event PictureProcessed { get; private set; } = null!; + public Event AudioProcessed { get; private set; } = null!; + public Event PictureFailed { get; private set; } = null!; + public Event AudioFailed { get; private set; } = null!; + + public TrackProcessingStateMachine() { - config.CorrelateById(context => context.Message.TrackId); - config.OnMissingInstance(instance => instance.Discard()); - }); - Event(() => AudioProcessed, config => - { - config.CorrelateById(context => context.Message.TrackId); - config.OnMissingInstance(instance => instance.Discard()); - }); - Event(() => PictureFailed, config => - { - config.CorrelateById(context => context.Message.TrackId); - config.OnMissingInstance(instance => instance.Discard()); - }); - Event(() => AudioFailed, config => - { - config.CorrelateById(context => context.Message.TrackId); - config.OnMissingInstance(instance => instance.Discard()); - }); + InstanceState(state => state.CurrentState); - Initially( - When(ProcessingStarted) - .Then(context => - { - context.Saga.CreatedAt = DateTime.UtcNow; - context.Saga.UpdatedAt = DateTime.UtcNow; - context.Saga.Bucket = context.Message.Bucket; - context.Saga.PictureKey = context.Message.PictureDestinationKey; - context.Saga.AudioKey = context.Message.AudioDestinationKey; - }) - .TransitionTo(Processing)); + Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.TrackId)); + Event(() => PictureProcessed, config => + { + config.CorrelateById(context => context.Message.TrackId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => AudioProcessed, config => + { + config.CorrelateById(context => context.Message.TrackId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => PictureFailed, config => + { + config.CorrelateById(context => context.Message.TrackId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => AudioFailed, config => + { + config.CorrelateById(context => context.Message.TrackId); + config.OnMissingInstance(instance => instance.Discard()); + }); - During(Processing, - When(PictureProcessed) - .Then(context => - { - context.Saga.PictureProcessed = true; - context.Saga.UpdatedAt = DateTime.UtcNow; - }) - .If(context => context.Saga.AudioProcessed, binder => binder.Finalize()), - When(AudioProcessed) - .Then(context => - { - context.Saga.AudioProcessed = true; - context.Saga.UpdatedAt = DateTime.UtcNow; - }) - .If(context => context.Saga.PictureProcessed, binder => binder.Finalize()), - When(PictureFailed) - .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) - .TransitionTo(Failed) - .Publish(context => new TrackProcessingFailed( - context.Saga.CorrelationId, - context.Saga.Bucket, - context.Saga.PictureKey, - context.Saga.AudioKey)), - When(AudioFailed) - .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) - .TransitionTo(Failed) - .Publish(context => new TrackProcessingFailed( - context.Saga.CorrelationId, - context.Saga.Bucket, - context.Saga.PictureKey, - context.Saga.AudioKey))); + Initially( + When(ProcessingStarted) + .Then(context => + { + context.Saga.CreatedAt = DateTime.UtcNow; + context.Saga.UpdatedAt = DateTime.UtcNow; + context.Saga.Bucket = context.Message.Bucket; + context.Saga.PictureKey = context.Message.PictureDestinationKey; + context.Saga.AudioKey = context.Message.AudioDestinationKey; + }) + .TransitionTo(Processing)); - During(Failed, - Ignore(PictureProcessed), - Ignore(AudioProcessed), - Ignore(PictureFailed), - Ignore(AudioFailed)); + During(Processing, + When(PictureProcessed) + .Then(context => + { + context.Saga.PictureProcessed = true; + context.Saga.UpdatedAt = DateTime.UtcNow; + }) + .If(context => context.Saga.AudioProcessed, binder => binder.Finalize()), + When(AudioProcessed) + .Then(context => + { + context.Saga.AudioProcessed = true; + context.Saga.UpdatedAt = DateTime.UtcNow; + }) + .If(context => context.Saga.PictureProcessed, binder => binder.Finalize()), + When(PictureFailed) + .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) + .TransitionTo(Failed) + .Publish(context => new TrackProcessingFailed( + context.Saga.CorrelationId, + context.Saga.Bucket, + context.Saga.PictureKey, + context.Saga.AudioKey)), + When(AudioFailed) + .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) + .TransitionTo(Failed) + .Publish(context => new TrackProcessingFailed( + context.Saga.CorrelationId, + context.Saga.Bucket, + context.Saga.PictureKey, + context.Saga.AudioKey))); - SetCompletedWhenFinalized(); + During(Failed, + Ignore(PictureProcessed), + Ignore(AudioProcessed), + Ignore(PictureFailed), + Ignore(AudioFailed)); + + SetCompletedWhenFinalized(); + } } } diff --git a/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs index 2178791..b75d67b 100644 --- a/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs @@ -8,46 +8,47 @@ using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; -namespace Musify.Infrastructure.Observability; - -public static class OpenTelemetryDependencyInjection +namespace Musify.Infrastructure.Observability { - public static IServiceCollection AddObservability(this IServiceCollection services, IConfiguration configuration) + public static class OpenTelemetryDependencyInjection { - services.AddValidatedOptions(configuration); + public static IServiceCollection AddObservability(this IServiceCollection services, IConfiguration configuration) + { + services.AddValidatedOptions(configuration); - services - .AddOptions() - .Configure((exporterOptions, openTelemetryConfiguration) => - { - exporterOptions.Endpoint = new Uri(openTelemetryConfiguration.OtlpEndpoint); - }); + services + .AddOptions() + .Configure((exporterOptions, openTelemetryConfiguration) => + { + exporterOptions.Endpoint = new Uri(openTelemetryConfiguration.OtlpEndpoint); + }); - services - .AddOpenTelemetry() - .ConfigureResource(resource => - { - resource.AddService("Musify"); - }) - .WithTracing(tracing => - tracing.AddSource("MassTransit") - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddEntityFrameworkCoreInstrumentation() - .AddOtlpExporter() - ) - .WithMetrics(metrics => - metrics.AddMeter("MassTransit") - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation() - .AddProcessInstrumentation() - .AddOtlpExporter() - ) - .WithLogging(logging => - logging.AddOtlpExporter() - ); + services + .AddOpenTelemetry() + .ConfigureResource(resource => + { + resource.AddService("Musify"); + }) + .WithTracing(tracing => + tracing.AddSource("MassTransit") + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddEntityFrameworkCoreInstrumentation() + .AddOtlpExporter() + ) + .WithMetrics(metrics => + metrics.AddMeter("MassTransit") + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddProcessInstrumentation() + .AddOtlpExporter() + ) + .WithLogging(logging => + logging.AddOtlpExporter() + ); - return services; + return services; + } } } diff --git a/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs b/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs index 38681b8..6a490c5 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs @@ -2,42 +2,43 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Musify.Domain.Abstractions; -namespace Musify.Infrastructure.Persistence; - -public sealed class AuditableEntityInterceptor : SaveChangesInterceptor +namespace Musify.Infrastructure.Persistence { - public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + public sealed class AuditableEntityInterceptor : SaveChangesInterceptor { - ApplyTimestamps(eventData.Context); - return base.SavingChanges(eventData, result); - } - - public override ValueTask> SavingChangesAsync( - DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) - { - ApplyTimestamps(eventData.Context); - return base.SavingChangesAsync(eventData, result, cancellationToken); - } - - private static void ApplyTimestamps(DbContext? context) - { - if (context == null) - return; - - context.ChangeTracker.DetectChanges(); - - var now = DateTime.UtcNow; - - foreach (var entry in context.ChangeTracker.Entries()) + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) { - if (entry.State == EntityState.Added) + ApplyTimestamps(eventData.Context); + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + ApplyTimestamps(eventData.Context); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private static void ApplyTimestamps(DbContext? context) + { + if (context == null) + return; + + context.ChangeTracker.DetectChanges(); + + var now = DateTime.UtcNow; + + foreach (var entry in context.ChangeTracker.Entries()) { - entry.Entity.CreatedAt = now; - entry.Entity.UpdatedAt = now; - } - else if (entry.State == EntityState.Modified) - { - entry.Property(nameof(IAuditable.UpdatedAt)).CurrentValue = now; + if (entry.State == EntityState.Added) + { + entry.Entity.CreatedAt = now; + entry.Entity.UpdatedAt = now; + } + else if (entry.State == EntityState.Modified) + { + entry.Property(nameof(IAuditable.UpdatedAt)).CurrentValue = now; + } } } } diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index 929c3db..149ec10 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -8,81 +8,82 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.Configuration; using Npgsql; -namespace Musify.Infrastructure.Persistence; - -public class Database(DatabaseConfiguration configuration) - : DbContext, Musify.Application.Contracts.IDatabase +namespace Musify.Infrastructure.Persistence { - private static readonly AuditableEntityInterceptor AuditInterceptor = new(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + public class Database(DatabaseConfiguration configuration) + : DbContext, Musify.Application.Contracts.IDatabase { - optionsBuilder - .UseNpgsql(configuration.ConnectionString, o => o.MapEnum("genre")) - .AddInterceptors(AuditInterceptor); - } + private static readonly AuditableEntityInterceptor AuditInterceptor = new(); - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.AddInboxStateEntity(); - modelBuilder.AddOutboxMessageEntity(); - modelBuilder.AddOutboxStateEntity(); - - ModelConfiguration.Apply(modelBuilder); - } - - public DbSet Users => Set(); - - public DbSet Tracks => Set(); - - public DbSet PlayLists => Set(); - - public DbSet Albums => Set(); - - public DbSet AlbumHasTracks => Set(); - - public DbSet PlayListHasTracks => Set(); - - public DbSet Mixes => Set(); - - public DbSet MixItems => Set(); - - public DbSet UploadIntents => Set(); - - public DbSet ListeningHistories => Set(); - - public DbSet TrackLikes => Set(); - - public DbSet TrackTags => Set(); - - public DbSet UserFollows => Set(); - - public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) - { - var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); - return new DatabaseTransaction(transaction); - } - - public async Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken) - { - try + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - await SaveChangesAsync(cancellationToken); - return Result.Success; + optionsBuilder + .UseNpgsql(configuration.ConnectionString, o => o.MapEnum("genre")) + .AddInterceptors(AuditInterceptor); } - catch (DbUpdateException exception) when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) + + protected override void OnModelCreating(ModelBuilder modelBuilder) { - return onUniqueViolation; + base.OnModelCreating(modelBuilder); + + modelBuilder.AddInboxStateEntity(); + modelBuilder.AddOutboxMessageEntity(); + modelBuilder.AddOutboxStateEntity(); + + ModelConfiguration.Apply(modelBuilder); } - } - private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction - { - public Task CommitAsync(CancellationToken cancellationToken) => - inner.CommitAsync(cancellationToken); + public DbSet Users => Set(); - public ValueTask DisposeAsync() => inner.DisposeAsync(); + public DbSet Tracks => Set(); + + public DbSet PlayLists => Set(); + + public DbSet Albums => Set(); + + public DbSet AlbumHasTracks => Set(); + + public DbSet PlayListHasTracks => Set(); + + public DbSet Mixes => Set(); + + public DbSet MixItems => Set(); + + public DbSet UploadIntents => Set(); + + public DbSet ListeningHistories => Set(); + + public DbSet TrackLikes => Set(); + + public DbSet TrackTags => Set(); + + public DbSet UserFollows => Set(); + + public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) + { + var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); + return new DatabaseTransaction(transaction); + } + + public async Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken) + { + try + { + await SaveChangesAsync(cancellationToken); + return Result.Success; + } + catch (DbUpdateException exception) when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) + { + return onUniqueViolation; + } + } + + private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction + { + public Task CommitAsync(CancellationToken cancellationToken) => + inner.CommitAsync(cancellationToken); + + public ValueTask DisposeAsync() => inner.DisposeAsync(); + } } } diff --git a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs index 9ba10fe..7b28400 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs @@ -4,18 +4,19 @@ using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; -namespace Musify.Infrastructure.Persistence; - -public static class DatabaseDependencyInjection +namespace Musify.Infrastructure.Persistence { - public static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration) + public static class DatabaseDependencyInjection { - services.AddValidatedOptions(configuration); + public static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration) + { + services.AddValidatedOptions(configuration); - services.AddDbContext(); + services.AddDbContext(); - services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); + services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); - return services; + return services; + } } } diff --git a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs index b4e68ee..6b731c4 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs @@ -2,24 +2,25 @@ using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; using Musify.Infrastructure.Configuration; -namespace Musify.Infrastructure.Persistence; - -public class DatabaseDesignTimeFactory : IDesignTimeDbContextFactory +namespace Musify.Infrastructure.Persistence { - public Database CreateDbContext(string[] args) + public class DatabaseDesignTimeFactory : IDesignTimeDbContextFactory { - var configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("DesignSettings.json", optional: true) - .AddUserSecrets() - .AddEnvironmentVariables() - .Build(); + public Database CreateDbContext(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("DesignSettings.json", optional: true) + .AddUserSecrets() + .AddEnvironmentVariables() + .Build(); - var databaseConfiguration = configuration - .GetRequiredSection(DatabaseConfiguration.SectionName) - .Get() - ?? throw new InvalidOperationException($"{DatabaseConfiguration.SectionName} configuration section not found."); + var databaseConfiguration = configuration + .GetRequiredSection(DatabaseConfiguration.SectionName) + .Get() + ?? throw new InvalidOperationException($"{DatabaseConfiguration.SectionName} configuration section not found."); - return new Database(databaseConfiguration); + return new Database(databaseConfiguration); + } } } diff --git a/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs b/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs index 4e44ae1..dfeac50 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs @@ -3,152 +3,153 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Sagas; -namespace Musify.Infrastructure.Persistence; - -/// -/// Entity model shared by and the in-memory test database, so both always agree. -/// -public static class ModelConfiguration +namespace Musify.Infrastructure.Persistence { - public static void Apply(ModelBuilder modelBuilder) + /// + /// Entity model shared by and the in-memory test database, so both always agree. + /// + public static class ModelConfiguration { - var trackProcessing = modelBuilder.Entity(); - trackProcessing.HasKey(state => state.CorrelationId); - trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); - trackProcessing.Property(state => state.CurrentState).HasMaxLength(64); + public static void Apply(ModelBuilder modelBuilder) + { + var trackProcessing = modelBuilder.Entity(); + trackProcessing.HasKey(state => state.CorrelationId); + trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); + trackProcessing.Property(state => state.CurrentState).HasMaxLength(64); - modelBuilder.Entity() - .OwnsOne(track => track.Pictures, pictures => - { - pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); - pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64); - pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64); - pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64); - pictures.Property(p => p.ProcessingStatus).HasColumnName("PicturesProcessingStatus"); - }); - modelBuilder.Entity().Navigation(track => track.Pictures).IsRequired(); + modelBuilder.Entity() + .OwnsOne(track => track.Pictures, pictures => + { + pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); + pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64); + pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64); + pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64); + pictures.Property(p => p.ProcessingStatus).HasColumnName("PicturesProcessingStatus"); + }); + modelBuilder.Entity().Navigation(track => track.Pictures).IsRequired(); - modelBuilder.Entity() - .OwnsOne(track => track.Audio, audio => - { - audio.Property(a => a.OriginalName).HasColumnName("OriginalAudioName").HasMaxLength(64); - audio.Property(a => a.FolderName).HasColumnName("AudioFolderName").HasMaxLength(64); - audio.Property(a => a.TranscodeStatus).HasColumnName("AudioTranscodeProcessingStatus"); - }); - modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); + modelBuilder.Entity() + .OwnsOne(track => track.Audio, audio => + { + audio.Property(a => a.OriginalName).HasColumnName("OriginalAudioName").HasMaxLength(64); + audio.Property(a => a.FolderName).HasColumnName("AudioFolderName").HasMaxLength(64); + audio.Property(a => a.TranscodeStatus).HasColumnName("AudioTranscodeProcessingStatus"); + }); + modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); - modelBuilder.Entity() - .HasOne(track => track.Owner) - .WithMany() - .HasForeignKey(track => track.OwnerUserId) - .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(track => track.Owner) + .WithMany() + .HasForeignKey(track => track.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .Property(playList => playList.Visibility) - .HasDefaultValue(PlayListVisibility.Private); + modelBuilder.Entity() + .Property(playList => playList.Visibility) + .HasDefaultValue(PlayListVisibility.Private); - modelBuilder.Entity() - .OwnsOne(playList => playList.Pictures, pictures => - { - pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64).IsRequired(false); - pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false); - pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false); - pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false); - }); - modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(false); + modelBuilder.Entity() + .OwnsOne(playList => playList.Pictures, pictures => + { + pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false); + }); + modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(false); - modelBuilder.Entity() - .OwnsOne(album => album.Pictures, pictures => - { - pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); - pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false); - pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false); - pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false); - }); - modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); + modelBuilder.Entity() + .OwnsOne(album => album.Pictures, pictures => + { + pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); + pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false); + }); + modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); - modelBuilder.Entity() - .HasOne(album => album.Owner) - .WithMany() - .HasForeignKey(album => album.OwnerUserId) - .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(album => album.Owner) + .WithMany() + .HasForeignKey(album => album.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .HasOne(albumTrack => albumTrack.Album) - .WithMany(album => album.AlbumTracks) - .HasForeignKey(albumTrack => albumTrack.AlbumId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(albumTrack => albumTrack.Album) + .WithMany(album => album.AlbumTracks) + .HasForeignKey(albumTrack => albumTrack.AlbumId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasOne(albumTrack => albumTrack.Track) - .WithMany(track => track.AlbumTracks) - .HasForeignKey(albumTrack => albumTrack.TrackId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(albumTrack => albumTrack.Track) + .WithMany(track => track.AlbumTracks) + .HasForeignKey(albumTrack => albumTrack.TrackId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) - .IsUnique(); + modelBuilder.Entity() + .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) + .IsUnique(); - modelBuilder.Entity() - .HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId }) - .IsUnique(); + modelBuilder.Entity() + .HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId }) + .IsUnique(); - modelBuilder.Entity() - .HasIndex(mix => new { mix.UserId, mix.Position }); + modelBuilder.Entity() + .HasIndex(mix => new { mix.UserId, mix.Position }); - modelBuilder.Entity() - .HasOne(item => item.Mix) - .WithMany(mix => mix.Items) - .HasForeignKey(item => item.MixId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(item => item.Mix) + .WithMany(mix => mix.Items) + .HasForeignKey(item => item.MixId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasOne(item => item.Track) - .WithMany() - .HasForeignKey(item => item.TrackId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(item => item.Track) + .WithMany() + .HasForeignKey(item => item.TrackId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasIndex(item => new { item.MixId, item.Position }); + modelBuilder.Entity() + .HasIndex(item => new { item.MixId, item.Position }); - modelBuilder.Entity() - .HasIndex(like => new { like.UserId, like.TrackId }) - .IsUnique(); + modelBuilder.Entity() + .HasIndex(like => new { like.UserId, like.TrackId }) + .IsUnique(); - modelBuilder.Entity() - .HasOne(trackTag => trackTag.Track) - .WithMany(track => track.Tags) - .HasForeignKey(trackTag => trackTag.TrackId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(trackTag => trackTag.Track) + .WithMany(track => track.Tags) + .HasForeignKey(trackTag => trackTag.TrackId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag }) - .IsUnique(); + modelBuilder.Entity() + .HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag }) + .IsUnique(); - modelBuilder.Entity() - .HasOne(follow => follow.Follower) - .WithMany() - .HasForeignKey(follow => follow.FollowerId) - .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(follow => follow.Follower) + .WithMany() + .HasForeignKey(follow => follow.FollowerId) + .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .HasOne(follow => follow.Followed) - .WithMany() - .HasForeignKey(follow => follow.FollowedId) - .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(follow => follow.Followed) + .WithMany() + .HasForeignKey(follow => follow.FollowedId) + .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .HasIndex(follow => new { follow.FollowerId, follow.FollowedId }) - .IsUnique(); + modelBuilder.Entity() + .HasIndex(follow => new { follow.FollowerId, follow.FollowedId }) + .IsUnique(); - var playListProcessing = modelBuilder.Entity(); - playListProcessing.HasKey(state => state.CorrelationId); - playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); - playListProcessing.Property(state => state.CurrentState).HasMaxLength(64); + var playListProcessing = modelBuilder.Entity(); + playListProcessing.HasKey(state => state.CorrelationId); + playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); + playListProcessing.Property(state => state.CurrentState).HasMaxLength(64); - var albumProcessing = modelBuilder.Entity(); - albumProcessing.HasKey(state => state.CorrelationId); - albumProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); - albumProcessing.Property(state => state.CurrentState).HasMaxLength(64); + var albumProcessing = modelBuilder.Entity(); + albumProcessing.HasKey(state => state.CorrelationId); + albumProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); + albumProcessing.Property(state => state.CurrentState).HasMaxLength(64); + } } } diff --git a/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs b/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs index c671eb3..a83ddd1 100644 --- a/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs +++ b/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs @@ -5,171 +5,172 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; -namespace Musify.Infrastructure.Services; - -public sealed partial class AudioTranscoderService( - ILogger logger, - AudioConfiguration audioConfiguration) - : IAudioTranscoderService +namespace Musify.Infrastructure.Services { - private sealed record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError); - - [GeneratedRegex(@"Duration:\s*(\d+):(\d{2}):(\d{2}(?:\.\d+)?)", RegexOptions.IgnoreCase)] - private static partial Regex DurationRegex(); - - public async Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken) + public sealed partial class AudioTranscoderService( + ILogger logger, + AudioConfiguration audioConfiguration) + : IAudioTranscoderService { - Directory.CreateDirectory(destinationPath); + private sealed record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError); - logger.LogInformation("Starting audio transcoding for {DestinationPath}", destinationPath); + [GeneratedRegex(@"Duration:\s*(\d+):(\d{2}):(\d{2}(?:\.\d+)?)", RegexOptions.IgnoreCase)] + private static partial Regex DurationRegex(); - var executionResult = await ExecuteFfmpegAsync( - BuildAudioFileArguments(destinationPath, audioConfiguration.Ffmpeg), - destinationPath, - audioStream, - "TranscodeToAudioFile", - cancellationToken); - - if (executionResult.ExitCode != 0) + public async Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken) { - logger.LogError("Audio transcoding failed with exit code {ExitCode}. Output: {StandardOutput}, Error: {StandardError}", - executionResult.ExitCode, executionResult.StandardOutput, executionResult.StandardError); - return new AudioTranscodeResult(executionResult.ExitCode, TimeSpan.Zero); - } + Directory.CreateDirectory(destinationPath); - logger.LogInformation("Audio transcoding completed for {DestinationPath}", destinationPath); + logger.LogInformation("Starting audio transcoding for {DestinationPath}", destinationPath); - var outputPath = Path.Combine(destinationPath, audioConfiguration.Ffmpeg.OutputFileName); - var duration = await GetAudioDurationAsync(outputPath, cancellationToken); - return new AudioTranscodeResult(executionResult.ExitCode, duration); - } + var executionResult = await ExecuteFfmpegAsync( + BuildAudioFileArguments(destinationPath, audioConfiguration.Ffmpeg), + destinationPath, + audioStream, + "TranscodeToAudioFile", + cancellationToken); - private async Task GetAudioDurationAsync(string filePath, CancellationToken cancellationToken) - { - var executionResult = await ExecuteFfmpegAsync( - BuildProbeDurationArguments(filePath), - Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory, - input: null, - "ProbeAudioDuration", - cancellationToken); - - var match = DurationRegex().Match(executionResult.StandardError); - if (!match.Success) - throw new InvalidOperationException($"Could not determine audio duration for {filePath}."); - - var hours = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); - var minutes = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture); - var seconds = double.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture); - - return new TimeSpan(hours, minutes, 0) + TimeSpan.FromSeconds(seconds); - } - - private async Task ExecuteFfmpegAsync( - string arguments, - string workingDirectory, - Stream? input, - string operationName, - CancellationToken cancellationToken) - { - using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: input != null); - process.Start(); - - logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId}) in {WorkingDirectory}", operationName, process.Id, workingDirectory); - - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - if (input != null) - { - if (input.CanSeek) - input.Position = 0; - - await input.CopyToAsync(process.StandardInput.BaseStream, cancellationToken); - process.StandardInput.Close(); - } - - return await WaitForProcessResultAsync(process, outputTask, errorTask, operationName, cancellationToken); - } - - private Process BuildProcess(string arguments, string workingDirectory, bool redirectStandardInput) - => new() - { - StartInfo = new ProcessStartInfo + if (executionResult.ExitCode != 0) { - FileName = audioConfiguration.Ffmpeg.ExecutableName, - WorkingDirectory = workingDirectory, - Arguments = arguments, - RedirectStandardInput = redirectStandardInput, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, + logger.LogError("Audio transcoding failed with exit code {ExitCode}. Output: {StandardOutput}, Error: {StandardError}", + executionResult.ExitCode, executionResult.StandardOutput, executionResult.StandardError); + return new AudioTranscodeResult(executionResult.ExitCode, TimeSpan.Zero); } - }; - private async Task WaitForProcessResultAsync( - Process process, - Task outputTask, - Task errorTask, - string operationName, - CancellationToken cancellationToken) - { - using var linkedCancellationTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - linkedCancellationTokens.CancelAfter(audioConfiguration.TranscodingTimeout); + logger.LogInformation("Audio transcoding completed for {DestinationPath}", destinationPath); - try - { - await process.WaitForExitAsync(linkedCancellationTokens.Token); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - TryKillProcess(process); - throw new TimeoutException($"ffmpeg execution exceeded timeout {audioConfiguration.TranscodingTimeout} (PID: {process.Id})."); + var outputPath = Path.Combine(destinationPath, audioConfiguration.Ffmpeg.OutputFileName); + var duration = await GetAudioDurationAsync(outputPath, cancellationToken); + return new AudioTranscodeResult(executionResult.ExitCode, duration); } - var standardError = await errorTask; - var standardOutput = await outputTask; + private async Task GetAudioDurationAsync(string filePath, CancellationToken cancellationToken) + { + var executionResult = await ExecuteFfmpegAsync( + BuildProbeDurationArguments(filePath), + Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory, + input: null, + "ProbeAudioDuration", + cancellationToken); - logger.LogDebug("[{Operation}] ffmpeg finished (PID: {ProcessId}). Exit code: {ExitCode}, Output: {OutputLength}B, Error: {ErrorLength}B", - operationName, process.Id, process.ExitCode, standardOutput.Length, standardError.Length); + var match = DurationRegex().Match(executionResult.StandardError); + if (!match.Success) + throw new InvalidOperationException($"Could not determine audio duration for {filePath}."); - return new FfmpegExecutionResult(process.ExitCode, standardOutput, standardError); + var hours = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + var minutes = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture); + var seconds = double.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture); + + return new TimeSpan(hours, minutes, 0) + TimeSpan.FromSeconds(seconds); + } + + private async Task ExecuteFfmpegAsync( + string arguments, + string workingDirectory, + Stream? input, + string operationName, + CancellationToken cancellationToken) + { + using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: input != null); + process.Start(); + + logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId}) in {WorkingDirectory}", operationName, process.Id, workingDirectory); + + var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + if (input != null) + { + if (input.CanSeek) + input.Position = 0; + + await input.CopyToAsync(process.StandardInput.BaseStream, cancellationToken); + process.StandardInput.Close(); + } + + return await WaitForProcessResultAsync(process, outputTask, errorTask, operationName, cancellationToken); + } + + private Process BuildProcess(string arguments, string workingDirectory, bool redirectStandardInput) + => new() + { + StartInfo = new ProcessStartInfo + { + FileName = audioConfiguration.Ffmpeg.ExecutableName, + WorkingDirectory = workingDirectory, + Arguments = arguments, + RedirectStandardInput = redirectStandardInput, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + } + }; + + private async Task WaitForProcessResultAsync( + Process process, + Task outputTask, + Task errorTask, + string operationName, + CancellationToken cancellationToken) + { + using var linkedCancellationTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linkedCancellationTokens.CancelAfter(audioConfiguration.TranscodingTimeout); + + try + { + await process.WaitForExitAsync(linkedCancellationTokens.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + TryKillProcess(process); + throw new TimeoutException($"ffmpeg execution exceeded timeout {audioConfiguration.TranscodingTimeout} (PID: {process.Id})."); + } + + var standardError = await errorTask; + var standardOutput = await outputTask; + + logger.LogDebug("[{Operation}] ffmpeg finished (PID: {ProcessId}). Exit code: {ExitCode}, Output: {OutputLength}B, Error: {ErrorLength}B", + operationName, process.Id, process.ExitCode, standardOutput.Length, standardError.Length); + + return new FfmpegExecutionResult(process.ExitCode, standardOutput, standardError); + } + + private void TryKillProcess(Process process) + { + try + { + if (!process.HasExited) + process.Kill(); + } + catch (InvalidOperationException exception) + { + logger.LogWarning(exception, "Could not kill ffmpeg process (PID: {ProcessId}); it had already exited", process.Id); + } + } + + private static string BuildAudioFileArguments(string outputDirectory, FfmpegConfiguration ffmpegConfiguration) + { + var outputPath = Path.Combine(outputDirectory, ffmpegConfiguration.OutputFileName); + var additionalArguments = string.IsNullOrWhiteSpace(ffmpegConfiguration.AdditionalArguments) + ? string.Empty + : $" {ffmpegConfiguration.AdditionalArguments.Trim()}"; + + return "-y -hide_banner -loglevel error " + + "-i pipe:0 " + + "-vn " + + "-map 0:a " + + $"-c:a {ffmpegConfiguration.AudioCodec} " + + $"-b:a {ffmpegConfiguration.AudioBitrate} " + + $"-ac {ffmpegConfiguration.AudioChannels} " + + $"-ar {ffmpegConfiguration.AudioSampleRate} " + + $"-profile:a {ffmpegConfiguration.AudioProfile} " + + "-movflags +faststart" + + additionalArguments + + $" \"{outputPath}\""; + } + + private static string BuildProbeDurationArguments(string filePath) + => $"-hide_banner -i \"{filePath}\" -f null -"; } - - private void TryKillProcess(Process process) - { - try - { - if (!process.HasExited) - process.Kill(); - } - catch (InvalidOperationException exception) - { - logger.LogWarning(exception, "Could not kill ffmpeg process (PID: {ProcessId}); it had already exited", process.Id); - } - } - - private static string BuildAudioFileArguments(string outputDirectory, FfmpegConfiguration ffmpegConfiguration) - { - var outputPath = Path.Combine(outputDirectory, ffmpegConfiguration.OutputFileName); - var additionalArguments = string.IsNullOrWhiteSpace(ffmpegConfiguration.AdditionalArguments) - ? string.Empty - : $" {ffmpegConfiguration.AdditionalArguments.Trim()}"; - - return "-y -hide_banner -loglevel error " + - "-i pipe:0 " + - "-vn " + - "-map 0:a " + - $"-c:a {ffmpegConfiguration.AudioCodec} " + - $"-b:a {ffmpegConfiguration.AudioBitrate} " + - $"-ac {ffmpegConfiguration.AudioChannels} " + - $"-ar {ffmpegConfiguration.AudioSampleRate} " + - $"-profile:a {ffmpegConfiguration.AudioProfile} " + - "-movflags +faststart" + - additionalArguments + - $" \"{outputPath}\""; - } - - private static string BuildProbeDurationArguments(string filePath) - => $"-hide_banner -i \"{filePath}\" -f null -"; } diff --git a/backend/Core/Musify.Infrastructure/Services/PictureService.cs b/backend/Core/Musify.Infrastructure/Services/PictureService.cs index e938bb8..fff427d 100644 --- a/backend/Core/Musify.Infrastructure/Services/PictureService.cs +++ b/backend/Core/Musify.Infrastructure/Services/PictureService.cs @@ -3,29 +3,30 @@ using Musify.Application.Contracts; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Processing; -namespace Musify.Infrastructure.Services; - -public class PictureService(ILogger logger) - : IPictureService +namespace Musify.Infrastructure.Services { - public async Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken) + public class PictureService(ILogger logger) + : IPictureService { - logger.LogDebug("Resizing picture to {Width}x{Height}", width, height); - if (pictureStream.CanSeek && pictureStream.Position > 0) - pictureStream.Position = 0; - - using var picture = await Image.LoadAsync(pictureStream, cancellationToken); - picture.Mutate(options => options.Resize(new ResizeOptions + public async Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken) { - Size = new Size(width, height), - Mode = ResizeMode.Max - })); + logger.LogDebug("Resizing picture to {Width}x{Height}", width, height); + if (pictureStream.CanSeek && pictureStream.Position > 0) + pictureStream.Position = 0; - var memoryStream = new MemoryStream(); - await picture.SaveAsWebpAsync(memoryStream, cancellationToken); - memoryStream.Position = 0; + using var picture = await Image.LoadAsync(pictureStream, cancellationToken); + picture.Mutate(options => options.Resize(new ResizeOptions + { + Size = new Size(width, height), + Mode = ResizeMode.Max + })); - logger.LogDebug("Picture resized to {Width}x{Height}, length: {Length}", width, height, memoryStream.Length); - return memoryStream; + var memoryStream = new MemoryStream(); + await picture.SaveAsWebpAsync(memoryStream, cancellationToken); + memoryStream.Position = 0; + + logger.LogDebug("Picture resized to {Width}x{Height}, length: {Length}", width, height, memoryStream.Length); + return memoryStream; + } } } diff --git a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index 47e9c43..9c07da3 100644 --- a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -8,68 +8,69 @@ using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.Jobs; -namespace Musify.Infrastructure.Services; - -public static class ServicesDependencyInjection +namespace Musify.Infrastructure.Services { - public static IServiceCollection AddStorageService(this IServiceCollection services, IConfiguration configuration) + public static class ServicesDependencyInjection { - services.AddValidatedOptions(configuration); - - services.AddSingleton(serviceProvider => + public static IServiceCollection AddStorageService(this IServiceCollection services, IConfiguration configuration) { - var configuration = serviceProvider.GetRequiredService(); - var s3Configuration = new AmazonS3Config + services.AddValidatedOptions(configuration); + + services.AddSingleton(serviceProvider => { - ServiceURL = configuration.Address, - ForcePathStyle = configuration.ForcePathStyle, - UseHttp = configuration.UseHttp, - }; - return new AmazonS3Client(configuration.AccessKey, configuration.SecretAccessKey, s3Configuration); - }); + var configuration = serviceProvider.GetRequiredService(); + var s3Configuration = new AmazonS3Config + { + ServiceURL = configuration.Address, + ForcePathStyle = configuration.ForcePathStyle, + UseHttp = configuration.UseHttp, + }; + return new AmazonS3Client(configuration.AccessKey, configuration.SecretAccessKey, s3Configuration); + }); - services.AddSingleton(); - return services; - } + services.AddSingleton(); + return services; + } - public static IServiceCollection AddPictureService(this IServiceCollection services) - { - services.AddScoped(); - return services; - } - - public static IServiceCollection AddJobs(this IServiceCollection services) - { - services.AddHangfire((serviceProvider, hangfireConfiguration) => + public static IServiceCollection AddPictureService(this IServiceCollection services) { - var databaseConfiguration = serviceProvider.GetRequiredService(); - hangfireConfiguration.UsePostgreSqlStorage(options => - options.UseNpgsqlConnection(databaseConfiguration.ConnectionString)); - }); + services.AddScoped(); + return services; + } - services.AddHangfireServer(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddHostedService(); + public static IServiceCollection AddJobs(this IServiceCollection services) + { + services.AddHangfire((serviceProvider, hangfireConfiguration) => + { + var databaseConfiguration = serviceProvider.GetRequiredService(); + hangfireConfiguration.UsePostgreSqlStorage(options => + options.UseNpgsqlConnection(databaseConfiguration.ConnectionString)); + }); - return services; - } + services.AddHangfireServer(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddHostedService(); - public static IServiceCollection AddStreamTicketService(this IServiceCollection services, IConfiguration configuration) - { - services.AddValidatedOptions(configuration); + return services; + } - services.AddSingleton(); - return services; - } + public static IServiceCollection AddStreamTicketService(this IServiceCollection services, IConfiguration configuration) + { + services.AddValidatedOptions(configuration); - public static IServiceCollection AddAudioTranscoder(this IServiceCollection services, IConfiguration configuration) - { - services.AddValidatedOptions(configuration); + services.AddSingleton(); + return services; + } - services.AddScoped(); - return services; + public static IServiceCollection AddAudioTranscoder(this IServiceCollection services, IConfiguration configuration) + { + services.AddValidatedOptions(configuration); + + services.AddScoped(); + return services; + } } } diff --git a/backend/Core/Musify.Infrastructure/Services/StorageService.cs b/backend/Core/Musify.Infrastructure/Services/StorageService.cs index a44fb6c..5284297 100644 --- a/backend/Core/Musify.Infrastructure/Services/StorageService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StorageService.cs @@ -8,153 +8,154 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Infrastructure.Configuration; -namespace Musify.Infrastructure.Services; - -public sealed class StorageService( - IAmazonS3 amazonS3, - ILogger logger, - InfrastructureStorageConfiguration storageClientConfiguration) - : IStorageService +namespace Musify.Infrastructure.Services { - public async Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken) + public sealed class StorageService( + IAmazonS3 amazonS3, + ILogger logger, + InfrastructureStorageConfiguration storageClientConfiguration) + : IStorageService { - try + public async Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken) { - var response = await amazonS3.GetObjectAsync( - new GetObjectRequest { BucketName = bucket, Key = key }, cancellationToken); - logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key); - return response.ResponseStream; + try + { + var response = await amazonS3.GetObjectAsync( + new GetObjectRequest { BucketName = bucket, Key = key }, cancellationToken); + logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key); + return response.ResponseStream; + } + catch (AmazonS3Exception exception) when (exception.ErrorCode == "NoSuchKey") + { + logger.LogDebug("File not found in S3 {Bucket}/{Key}", bucket, key); + return null; + } } - catch (AmazonS3Exception exception) when (exception.ErrorCode == "NoSuchKey") + + public async Task GetUploadUrlAsync( + string bucket, + string key, + string contentType, + TimeSpan expirationTime, + bool preventOverwrite = true, + CancellationToken cancellationToken = default) { - logger.LogDebug("File not found in S3 {Bucket}/{Key}", bucket, key); - return null; + var request = new GetPreSignedUrlRequest + { + BucketName = bucket, + Key = key, + Expires = DateTime.UtcNow + expirationTime, + Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS, + Verb = HttpVerb.PUT, + ContentType = contentType + }; + + if (preventOverwrite) + request.Headers["If-None-Match"] = "*"; + + logger.LogDebug("Generating pre-signed upload URL for {Bucket}/{Key}", bucket, key); + return await amazonS3.GetPreSignedURLAsync(request); } - } - public async Task GetUploadUrlAsync( - string bucket, - string key, - string contentType, - TimeSpan expirationTime, - bool preventOverwrite = true, - CancellationToken cancellationToken = default) - { - var request = new GetPreSignedUrlRequest + public async Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken) { - BucketName = bucket, - Key = key, - Expires = DateTime.UtcNow + expirationTime, - Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS, - Verb = HttpVerb.PUT, - ContentType = contentType - }; - - if (preventOverwrite) - request.Headers["If-None-Match"] = "*"; - - logger.LogDebug("Generating pre-signed upload URL for {Bucket}/{Key}", bucket, key); - return await amazonS3.GetPreSignedURLAsync(request); - } - - public async Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken) - { - try - { - var response = await amazonS3.GetObjectMetadataAsync(bucket, key, cancellationToken); - return new ObjectMetaData(response.Headers.ContentType, response.ContentLength); + try + { + var response = await amazonS3.GetObjectMetadataAsync(bucket, key, cancellationToken); + return new ObjectMetaData(response.Headers.ContentType, response.ContentLength); + } + catch (AmazonS3Exception exception) when (exception.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } } - catch (AmazonS3Exception exception) when (exception.StatusCode == System.Net.HttpStatusCode.NotFound) + + public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken) { - return null; + logger.LogDebug("Removing file from S3 {Bucket}/{Key}", bucket, key); + await amazonS3.DeleteObjectAsync(new DeleteObjectRequest { BucketName = bucket, Key = key }, cancellationToken); } - } - public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken) - { - logger.LogDebug("Removing file from S3 {Bucket}/{Key}", bucket, key); - await amazonS3.DeleteObjectAsync(new DeleteObjectRequest { BucketName = bucket, Key = key }, cancellationToken); - } - - public async Task> TransferFilesAsync( - string sourceDirectory, string bucket, string route, CancellationToken cancellationToken) - { - using var transferUtility = new TransferUtility(amazonS3); - - var uploadedKeys = new List(); - - foreach (var filePath in Directory.GetFiles(sourceDirectory, "*")) + public async Task> TransferFilesAsync( + string sourceDirectory, string bucket, string route, CancellationToken cancellationToken) { - var key = StorageKey.Combine(route, Path.GetFileName(filePath)); + using var transferUtility = new TransferUtility(amazonS3); - await transferUtility.UploadAsync( - new TransferUtilityUploadRequest + var uploadedKeys = new List(); + + foreach (var filePath in Directory.GetFiles(sourceDirectory, "*")) + { + var key = StorageKey.Combine(route, Path.GetFileName(filePath)); + + await transferUtility.UploadAsync( + new TransferUtilityUploadRequest + { + FilePath = filePath, + BucketName = bucket, + Key = key, + ContentType = MimeUtility.GetMimeMapping(filePath) + }, + cancellationToken); + + uploadedKeys.Add(key); + logger.LogDebug("Transferred file to S3 {Bucket}/{Key}", bucket, key); + } + + logger.LogInformation("Transferred {Count} files to {Bucket}/{Route}", uploadedKeys.Count, bucket, route); + + return uploadedKeys; + } + + public async Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken) + { + await using var fileStream = File.OpenRead(filePath); + await UploadFileAsync(fileStream, MimeUtility.GetMimeMapping(filePath), bucket, key, cancellationToken); + } + + public async Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken) + { + await amazonS3.PutObjectAsync( + new PutObjectRequest { BucketName = bucket, ContentType = contentType, InputStream = sourceStream, Key = key }, + cancellationToken); + logger.LogDebug("Uploaded file to S3 {Bucket}/{Key}", bucket, key); + } + + public async Task CopyFileAsync( + string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, CancellationToken cancellationToken) + { + await amazonS3.CopyObjectAsync( + new CopyObjectRequest { - FilePath = filePath, - BucketName = bucket, - Key = key, - ContentType = MimeUtility.GetMimeMapping(filePath) + SourceBucket = sourceBucket, + SourceKey = sourceKey, + DestinationBucket = destinationBucket, + DestinationKey = destinationKey }, cancellationToken); - uploadedKeys.Add(key); - logger.LogDebug("Transferred file to S3 {Bucket}/{Key}", bucket, key); + logger.LogDebug("Copied file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", + sourceBucket, sourceKey, destinationBucket, destinationKey); } - logger.LogInformation("Transferred {Count} files to {Bucket}/{Route}", uploadedKeys.Count, bucket, route); - - return uploadedKeys; - } - - public async Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken) - { - await using var fileStream = File.OpenRead(filePath); - await UploadFileAsync(fileStream, MimeUtility.GetMimeMapping(filePath), bucket, key, cancellationToken); - } - - public async Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken) - { - await amazonS3.PutObjectAsync( - new PutObjectRequest { BucketName = bucket, ContentType = contentType, InputStream = sourceStream, Key = key }, - cancellationToken); - logger.LogDebug("Uploaded file to S3 {Bucket}/{Key}", bucket, key); - } - - public async Task CopyFileAsync( - string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, CancellationToken cancellationToken) - { - await amazonS3.CopyObjectAsync( - new CopyObjectRequest + public async IAsyncEnumerable ListObjectsAsync( + string bucket, string prefix, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var listRequest = new ListObjectsV2Request { - SourceBucket = sourceBucket, - SourceKey = sourceKey, - DestinationBucket = destinationBucket, - DestinationKey = destinationKey - }, - cancellationToken); + BucketName = bucket, + Prefix = prefix.EndsWith('/') ? prefix : $"{prefix}/" + }; - logger.LogDebug("Copied file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", - sourceBucket, sourceKey, destinationBucket, destinationKey); - } + ListObjectsV2Response listResponse; + do + { + listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken); + foreach (var s3Object in listResponse.S3Objects ?? []) + yield return new StorageObject(s3Object.Key, (s3Object.LastModified ?? DateTime.UtcNow).ToUniversalTime()); - public async IAsyncEnumerable ListObjectsAsync( - string bucket, string prefix, [EnumeratorCancellation] CancellationToken cancellationToken) - { - var listRequest = new ListObjectsV2Request - { - BucketName = bucket, - Prefix = prefix.EndsWith('/') ? prefix : $"{prefix}/" - }; - - ListObjectsV2Response listResponse; - do - { - listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken); - foreach (var s3Object in listResponse.S3Objects ?? []) - yield return new StorageObject(s3Object.Key, (s3Object.LastModified ?? DateTime.UtcNow).ToUniversalTime()); - - listRequest.ContinuationToken = listResponse.NextContinuationToken; + listRequest.ContinuationToken = listResponse.NextContinuationToken; + } + while (listResponse.IsTruncated == true); } - while (listResponse.IsTruncated == true); } } diff --git a/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs b/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs index f52e92a..575fad7 100644 --- a/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs @@ -5,50 +5,51 @@ using Microsoft.IdentityModel.Tokens; using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; -namespace Musify.Infrastructure.Services; - -public sealed class StreamTicketService : IStreamTicketService, IDisposable +namespace Musify.Infrastructure.Services { - private readonly StreamTicketConfiguration configuration; - private readonly RSA rsa; - private readonly SigningCredentials signingCredentials; - private readonly JsonWebTokenHandler tokenHandler = new(); - - public StreamTicketService(StreamTicketConfiguration configuration) + public sealed class StreamTicketService : IStreamTicketService, IDisposable { - this.configuration = configuration; + private readonly StreamTicketConfiguration configuration; + private readonly RSA rsa; + private readonly SigningCredentials signingCredentials; + private readonly JsonWebTokenHandler tokenHandler = new(); - rsa = RSA.Create(); - rsa.ImportFromPem(File.ReadAllText(configuration.PrivateKeyPath)); - - var key = new RsaSecurityKey(rsa); - signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); - } - - public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null) - { - var now = DateTimeOffset.UtcNow; - - var claims = new Dictionary { ["prefix"] = keyPrefix }; - if (userId != null) - claims["sub"] = userId.Value.ToString(CultureInfo.InvariantCulture); - if (maxBytes != null) - claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); - - var descriptor = new SecurityTokenDescriptor + public StreamTicketService(StreamTicketConfiguration configuration) { - Issuer = configuration.Issuer, - Audience = configuration.Audience, - IssuedAt = now.UtcDateTime, - NotBefore = now.UtcDateTime, - Expires = now.AddSeconds(configuration.TicketTtlSeconds).UtcDateTime, - Claims = claims, - SigningCredentials = signingCredentials, - }; + this.configuration = configuration; - var token = tokenHandler.CreateToken(descriptor); - return new StreamTicket(token, configuration.TicketTtlSeconds); + rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(configuration.PrivateKeyPath)); + + var key = new RsaSecurityKey(rsa); + signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); + } + + public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null) + { + var now = DateTimeOffset.UtcNow; + + var claims = new Dictionary { ["prefix"] = keyPrefix }; + if (userId != null) + claims["sub"] = userId.Value.ToString(CultureInfo.InvariantCulture); + if (maxBytes != null) + claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); + + var descriptor = new SecurityTokenDescriptor + { + Issuer = configuration.Issuer, + Audience = configuration.Audience, + IssuedAt = now.UtcDateTime, + NotBefore = now.UtcDateTime, + Expires = now.AddSeconds(configuration.TicketTtlSeconds).UtcDateTime, + Claims = claims, + SigningCredentials = signingCredentials, + }; + + var token = tokenHandler.CreateToken(descriptor); + return new StreamTicket(token, configuration.TicketTtlSeconds); + } + + public void Dispose() => rsa.Dispose(); } - - public void Dispose() => rsa.Dispose(); } diff --git a/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs b/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs index f7196c1..8c3f4c1 100644 --- a/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs +++ b/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs @@ -1,40 +1,41 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Configuration; -namespace Musify.Api.Authentication; - -public sealed class AuthenticationConfiguration : IConfigurationOptions +namespace Musify.Api.Authentication { - public static string SectionName => "Authentication"; + public sealed class AuthenticationConfiguration : IConfigurationOptions + { + public static string SectionName => "Authentication"; - [Required] - [Url] - public required string MetadataAddress { get; set; } + [Required] + [Url] + public required string MetadataAddress { get; set; } - [Required] - public required string IssuerAddress { get; set; } + [Required] + public required string IssuerAddress { get; set; } - [Required] - public required string AudienceAddress { get; set; } + [Required] + public required string AudienceAddress { get; set; } - [Required] - public required string ClientId { get; set; } + [Required] + public required string ClientId { get; set; } - public string? ClientSecret { get; set; } + public string? ClientSecret { get; set; } - [Required] - [Url] - public required string AuthorizationEndpoint { get; set; } + [Required] + [Url] + public required string AuthorizationEndpoint { get; set; } - [Required] - [Url] - public required string TokenEndpoint { get; set; } + [Required] + [Url] + public required string TokenEndpoint { get; set; } - [Required] - [MinLength(1)] - public required string[] Scopes { get; set; } + [Required] + [MinLength(1)] + public required string[] Scopes { get; set; } - public string? ScalarRedirectUri { get; set; } + public string? ScalarRedirectUri { get; set; } - public bool RequireHttpsMetadata { get; set; } = true; + public bool RequireHttpsMetadata { get; set; } = true; + } } diff --git a/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs b/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs index 6052f50..7cc0362 100644 --- a/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs +++ b/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs @@ -1,27 +1,28 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Musify.Application.Configuration; -namespace Musify.Api.Authentication; - -public static class AuthenticationExtensions +namespace Musify.Api.Authentication { - public static IServiceCollection AddAuthenticationConfiguration( - this IServiceCollection services, - IConfiguration configuration) + public static class AuthenticationExtensions { - services.AddValidatedOptions(configuration); + public static IServiceCollection AddAuthenticationConfiguration( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddValidatedOptions(configuration); - services.AddMemoryCache(); - services.AddHttpClient(); - services.AddScoped(); - services.ConfigureOptions(); + services.AddMemoryCache(); + services.AddHttpClient(); + services.AddScoped(); + services.ConfigureOptions(); - services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(); + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); - services.AddAuthorization(); + services.AddAuthorization(); - return services; + return services; + } } } diff --git a/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs b/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs index b6d15fb..f0f949a 100644 --- a/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs +++ b/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs @@ -1,35 +1,36 @@ using System.Reflection; using System.Security.Claims; -namespace Musify.Api.Authentication; - -public sealed class CurrentUser : IBindableFromHttpContext +namespace Musify.Api.Authentication { - private CurrentUser(ClaimsPrincipal principal) + public sealed class CurrentUser : IBindableFromHttpContext { - IsAuthenticated = principal.Identity?.IsAuthenticated ?? false; + private CurrentUser(ClaimsPrincipal principal) + { + IsAuthenticated = principal.Identity?.IsAuthenticated ?? false; - var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); - if (long.TryParse(rawId, out var id) && id != 0) - Id = id; + var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (long.TryParse(rawId, out var id) && id != 0) + Id = id; - Username = principal.FindFirstValue(ClaimTypes.Name); - FirstName = principal.FindFirstValue(ClaimTypes.GivenName); - LastName = principal.FindFirstValue(ClaimTypes.Surname); + Username = principal.FindFirstValue(ClaimTypes.Name); + FirstName = principal.FindFirstValue(ClaimTypes.GivenName); + LastName = principal.FindFirstValue(ClaimTypes.Surname); + } + + public bool IsAuthenticated { get; } + + public long? Id { get; } + + public string? Username { get; } + + public string? FirstName { get; } + + public string? LastName { get; } + + public long RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid."); + + public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) => + ValueTask.FromResult(new CurrentUser(context.User)); } - - public bool IsAuthenticated { get; } - - public long? Id { get; } - - public string? Username { get; } - - public string? FirstName { get; } - - public string? LastName { get; } - - public long RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid."); - - public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) => - ValueTask.FromResult(new CurrentUser(context.User)); } diff --git a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs index 2a705c9..c4d003b 100644 --- a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs +++ b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs @@ -7,135 +7,136 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Musify.Application.Users; -namespace Musify.Api.Authentication; - -public sealed class JwtBearerEventsHandler( - IMediator mediator, - IMemoryCache cache, - IHttpClientFactory httpClientFactory, - AuthenticationConfiguration authenticationConfiguration, - ILogger logger) : JwtBearerEvents +namespace Musify.Api.Authentication { - private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15); - - private sealed record UserInfoResponse( - [property: JsonPropertyName("name")] string? Name, - [property: JsonPropertyName("preferred_username")] string? PreferredUsername, - [property: JsonPropertyName("email")] string? Email, - [property: JsonPropertyName("given_name")] string? GivenName, - [property: JsonPropertyName("family_name")] string? FamilyName, - [property: JsonPropertyName("picture")] string? Picture); - - public override async Task TokenValidated(TokenValidatedContext context) + public sealed class JwtBearerEventsHandler( + IMediator mediator, + IMemoryCache cache, + IHttpClientFactory httpClientFactory, + AuthenticationConfiguration authenticationConfiguration, + ILogger logger) : JwtBearerEvents { - var principal = context.Principal!; + private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15); - var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); - if (!long.TryParse(rawId, out var userId)) + private sealed record UserInfoResponse( + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("preferred_username")] string? PreferredUsername, + [property: JsonPropertyName("email")] string? Email, + [property: JsonPropertyName("given_name")] string? GivenName, + [property: JsonPropertyName("family_name")] string? FamilyName, + [property: JsonPropertyName("picture")] string? Picture); + + public override async Task TokenValidated(TokenValidatedContext context) { - logger.LogWarning("Token subject '{Subject}' is not a numeric id; skipping user provisioning", rawId); - return; - } + var principal = context.Principal!; - var cacheKey = $"user-synced:{userId}"; - if (cache.TryGetValue(cacheKey, out _)) - return; - - var userInfo = await FetchUserInfoAsync(context, context.HttpContext.RequestAborted); - - var username = userInfo?.Name - ?? userInfo?.PreferredUsername - ?? principal.FindFirstValue("name") - ?? principal.FindFirstValue(ClaimTypes.Name) - ?? principal.FindFirstValue("preferred_username") - ?? userInfo?.Email - ?? principal.FindFirstValue("email") - ?? principal.FindFirstValue(ClaimTypes.Email) - ?? rawId!; - - var firstName = userInfo?.GivenName - ?? principal.FindFirstValue("given_name") - ?? principal.FindFirstValue(ClaimTypes.GivenName); - var lastName = userInfo?.FamilyName - ?? principal.FindFirstValue("family_name") - ?? principal.FindFirstValue(ClaimTypes.Surname); - var profilePictureUrl = userInfo?.Picture ?? principal.FindFirstValue("picture"); - - try - { - await mediator.Send( - new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl), - context.HttpContext.RequestAborted); - - cache.Set(cacheKey, true, UserSyncCacheTtl); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to sync user {UserId} during token validation", userId); - } - } - - private async Task FetchUserInfoAsync( - TokenValidatedContext context, CancellationToken cancellationToken) - { - var authorizationHeader = context.HttpContext.Request.Headers.Authorization.ToString(); - if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - return null; - - var accessToken = authorizationHeader["Bearer ".Length..].Trim(); - if (accessToken.Length == 0) - return null; - - try - { - var userInfoEndpoint = await ResolveUserInfoEndpointAsync(context, cancellationToken); - - var client = httpClientFactory.CreateClient(); - using var request = new HttpRequestMessage(HttpMethod.Get, userInfoEndpoint) + var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (!long.TryParse(rawId, out var userId)) { - Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) } - }; - - using var response = await client.SendAsync(request, cancellationToken); - if (!response.IsSuccessStatusCode) - { - logger.LogDebug("Userinfo request returned {StatusCode}", response.StatusCode); - return null; + logger.LogWarning("Token subject '{Subject}' is not a numeric id; skipping user provisioning", rawId); + return; } - return await response.Content.ReadFromJsonAsync(cancellationToken); + var cacheKey = $"user-synced:{userId}"; + if (cache.TryGetValue(cacheKey, out _)) + return; + + var userInfo = await FetchUserInfoAsync(context, context.HttpContext.RequestAborted); + + var username = userInfo?.Name + ?? userInfo?.PreferredUsername + ?? principal.FindFirstValue("name") + ?? principal.FindFirstValue(ClaimTypes.Name) + ?? principal.FindFirstValue("preferred_username") + ?? userInfo?.Email + ?? principal.FindFirstValue("email") + ?? principal.FindFirstValue(ClaimTypes.Email) + ?? rawId!; + + var firstName = userInfo?.GivenName + ?? principal.FindFirstValue("given_name") + ?? principal.FindFirstValue(ClaimTypes.GivenName); + var lastName = userInfo?.FamilyName + ?? principal.FindFirstValue("family_name") + ?? principal.FindFirstValue(ClaimTypes.Surname); + var profilePictureUrl = userInfo?.Picture ?? principal.FindFirstValue("picture"); + + try + { + await mediator.Send( + new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl), + context.HttpContext.RequestAborted); + + cache.Set(cacheKey, true, UserSyncCacheTtl); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to sync user {UserId} during token validation", userId); + } } - catch (Exception exception) + + private async Task FetchUserInfoAsync( + TokenValidatedContext context, CancellationToken cancellationToken) { - logger.LogWarning(exception, "Failed to fetch userinfo from identity provider"); - return null; - } - } + var authorizationHeader = context.HttpContext.Request.Headers.Authorization.ToString(); + if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return null; - private async Task ResolveUserInfoEndpointAsync( - TokenValidatedContext context, CancellationToken cancellationToken) - { - var configurationManager = context.Options.ConfigurationManager; - if (configurationManager != null) + var accessToken = authorizationHeader["Bearer ".Length..].Trim(); + if (accessToken.Length == 0) + return null; + + try + { + var userInfoEndpoint = await ResolveUserInfoEndpointAsync(context, cancellationToken); + + var client = httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, userInfoEndpoint) + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) } + }; + + using var response = await client.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + logger.LogDebug("Userinfo request returned {StatusCode}", response.StatusCode); + return null; + } + + return await response.Content.ReadFromJsonAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to fetch userinfo from identity provider"); + return null; + } + } + + private async Task ResolveUserInfoEndpointAsync( + TokenValidatedContext context, CancellationToken cancellationToken) { - var configuration = await configurationManager.GetConfigurationAsync(cancellationToken); - if (configuration is OpenIdConnectConfiguration { UserInfoEndpoint: { Length: > 0 } endpoint }) - return endpoint; + var configurationManager = context.Options.ConfigurationManager; + if (configurationManager != null) + { + var configuration = await configurationManager.GetConfigurationAsync(cancellationToken); + if (configuration is OpenIdConnectConfiguration { UserInfoEndpoint: { Length: > 0 } endpoint }) + return endpoint; + } + + return $"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo"; } - return $"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo"; - } + public override Task AuthenticationFailed(AuthenticationFailedContext context) + { + logger.LogWarning(context.Exception, "JWT authentication failed"); + return Task.CompletedTask; + } - public override Task AuthenticationFailed(AuthenticationFailedContext context) - { - logger.LogWarning(context.Exception, "JWT authentication failed"); - return Task.CompletedTask; - } - - public override Task Challenge(JwtBearerChallengeContext context) - { - if (context.AuthenticateFailure != null) - logger.LogWarning("JWT challenge: {Error} - {Description}", context.Error, context.ErrorDescription); - return Task.CompletedTask; + public override Task Challenge(JwtBearerChallengeContext context) + { + if (context.AuthenticateFailure != null) + logger.LogWarning("JWT challenge: {Error} - {Description}", context.Error, context.ErrorDescription); + return Task.CompletedTask; + } } } diff --git a/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs b/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs index 880fd13..c46dc02 100644 --- a/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs @@ -3,33 +3,34 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; -namespace Musify.Api.Authentication; - -public sealed class JwtBearerOptionsSetup(IOptions options) - : IConfigureNamedOptions +namespace Musify.Api.Authentication { - private readonly AuthenticationConfiguration configuration = options.Value; - - public void Configure(string? name, JwtBearerOptions options) + public sealed class JwtBearerOptionsSetup(IOptions options) + : IConfigureNamedOptions { - if (name != JwtBearerDefaults.AuthenticationScheme) - return; + private readonly AuthenticationConfiguration configuration = options.Value; - Configure(options); - } - - public void Configure(JwtBearerOptions options) - { - options.MetadataAddress = configuration.MetadataAddress; - options.RequireHttpsMetadata = configuration.RequireHttpsMetadata; - options.Audience = configuration.AudienceAddress; - options.TokenValidationParameters = new TokenValidationParameters + public void Configure(string? name, JwtBearerOptions options) { - ValidIssuer = configuration.IssuerAddress, - ValidAudience = configuration.AudienceAddress, - RoleClaimType = ClaimTypes.Role, - NameClaimType = ClaimTypes.Name, - }; - options.EventsType = typeof(JwtBearerEventsHandler); + if (name != JwtBearerDefaults.AuthenticationScheme) + return; + + Configure(options); + } + + public void Configure(JwtBearerOptions options) + { + options.MetadataAddress = configuration.MetadataAddress; + options.RequireHttpsMetadata = configuration.RequireHttpsMetadata; + options.Audience = configuration.AudienceAddress; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = configuration.IssuerAddress, + ValidAudience = configuration.AudienceAddress, + RoleClaimType = ClaimTypes.Role, + NameClaimType = ClaimTypes.Name, + }; + options.EventsType = typeof(JwtBearerEventsHandler); + } } } diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs index 6c0d0e5..3c25332 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs @@ -1,3 +1,4 @@ -namespace Musify.Api.DataTransferObjects; - -public record AddTracksRequest(IReadOnlyList TrackIds); +namespace Musify.Api.DataTransferObjects +{ + public record AddTracksRequest(IReadOnlyList TrackIds); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs index fa3292e..e8e8852 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs @@ -1,7 +1,8 @@ -namespace Musify.Api.DataTransferObjects.Albums; - -public record CreateAlbumRequest( - string Title, - string? Description, - int? ReleaseYear, - Guid PictureIntentId); +namespace Musify.Api.DataTransferObjects.Albums +{ + public record CreateAlbumRequest( + string Title, + string? Description, + int? ReleaseYear, + Guid PictureIntentId); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs index a814831..1d6d03d 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs @@ -1,7 +1,8 @@ -namespace Musify.Api.DataTransferObjects.Albums; - -public record UpdateAlbumRequest( - string Title, - string? Description, - int? ReleaseYear, - Guid? PictureIntentId); +namespace Musify.Api.DataTransferObjects.Albums +{ + public record UpdateAlbumRequest( + string Title, + string? Description, + int? ReleaseYear, + Guid? PictureIntentId); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs index 2ce34e2..d397a17 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs @@ -1,3 +1,4 @@ -namespace Musify.Api.DataTransferObjects.Likes; - -public record ToggleLikeRequest(Guid TrackId); +namespace Musify.Api.DataTransferObjects.Likes +{ + public record ToggleLikeRequest(Guid TrackId); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PictureUploadRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PictureUploadRequest.cs index c41becc..54ea555 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PictureUploadRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PictureUploadRequest.cs @@ -1,6 +1,7 @@ -namespace Musify.Api.DataTransferObjects; - -public record PictureUploadRequest( - string FileType, - string ContentType, - long? ExpectedSizeBytes = null); +namespace Musify.Api.DataTransferObjects +{ + public record PictureUploadRequest( + string FileType, + string ContentType, + long? ExpectedSizeBytes = null); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs index 5da333d..8cafadb 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs @@ -1,9 +1,10 @@ using Musify.Domain.ValueObjects; -namespace Musify.Api.DataTransferObjects.PlayLists; - -public record CreatePlayListRequest( - string Name, - string? Description, - Guid? PictureIntentId, - PlayListVisibility Visibility = PlayListVisibility.Private); +namespace Musify.Api.DataTransferObjects.PlayLists +{ + public record CreatePlayListRequest( + string Name, + string? Description, + Guid? PictureIntentId, + PlayListVisibility Visibility = PlayListVisibility.Private); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs index 78a4d11..a49124c 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs @@ -1,9 +1,10 @@ using Musify.Domain.ValueObjects; -namespace Musify.Api.DataTransferObjects.PlayLists; - -public record UpdatePlayListRequest( - string? Name, - string? Description, - Guid? PictureIntentId, - PlayListVisibility? Visibility = null); +namespace Musify.Api.DataTransferObjects.PlayLists +{ + public record UpdatePlayListRequest( + string? Name, + string? Description, + Guid? PictureIntentId, + PlayListVisibility? Visibility = null); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs index 5e95895..2180bec 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs @@ -1,10 +1,11 @@ using Musify.Domain.ValueObjects; -namespace Musify.Api.DataTransferObjects.Tracks; - -public record CreateTrackRequest( - string Title, - Guid PictureIntentId, - Guid AudioIntentId, - IReadOnlyCollection Tags, - bool IsExplicit = false); +namespace Musify.Api.DataTransferObjects.Tracks +{ + public record CreateTrackRequest( + string Title, + Guid PictureIntentId, + Guid AudioIntentId, + IReadOnlyCollection Tags, + bool IsExplicit = false); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RecordListeningProgressRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RecordListeningProgressRequest.cs index 32eff3c..951a990 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RecordListeningProgressRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RecordListeningProgressRequest.cs @@ -1,3 +1,4 @@ -namespace Musify.Api.DataTransferObjects.Tracks; - -public record RecordListeningProgressRequest(double PlayedSeconds); +namespace Musify.Api.DataTransferObjects.Tracks +{ + public record RecordListeningProgressRequest(double PlayedSeconds); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs index bd65125..dc7b887 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs @@ -1,9 +1,10 @@ -namespace Musify.Api.DataTransferObjects.Tracks; - -public record RequestTrackUploadUrlsRequest( - string PictureFileType, - string PictureContentType, - string AudioFileType, - string AudioContentType, - long? ExpectedPictureSizeBytes = null, - long? ExpectedAudioSizeBytes = null); +namespace Musify.Api.DataTransferObjects.Tracks +{ + public record RequestTrackUploadUrlsRequest( + string PictureFileType, + string PictureContentType, + string AudioFileType, + string AudioContentType, + long? ExpectedPictureSizeBytes = null, + long? ExpectedAudioSizeBytes = null); +} diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index 2d7a4df..9fdd920 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -13,263 +13,264 @@ using Musify.Application.Pictures.Responses; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -namespace Musify.Api.Endpoints; - -public static class AlbumEndpoints +namespace Musify.Api.Endpoints { - private const int MaxRecentAlbumsLimit = 50; - - public static IEndpointRouteBuilder MapAlbumEndpoints(this IEndpointRouteBuilder app) + public static class AlbumEndpoints { - var group = app.MapGroup("/albums") - .WithTags("Albums"); + private const int MaxRecentAlbumsLimit = 50; - group.MapGet("/", GetAlbums) - .AddEndpointFilter>() - .WithName("GetAlbums") - .WithSummary("Get paginated albums, optionally filtered by title") - .Produces>(); - - group.MapGet("/{id:guid}", GetAlbumById) - .WithName("GetAlbumById") - .WithSummary("Get an album by id") - .Produces() - .Produces(StatusCodes.Status404NotFound); - - group.MapGet("/users/{userId:long}", GetAlbumsByUserId) - .AddEndpointFilter>() - .WithName("GetAlbumsByUserId") - .WithSummary("Get paginated albums for a user") - .Produces>() - .Produces(StatusCodes.Status404NotFound); - - group.MapGet("/recent", GetRecentlyListenedAlbums) - .WithName("GetRecentlyListenedAlbums") - .WithSummary("Get the albums the current user listened to most recently") - .RequireAuthorization() - .Produces>() - .Produces(StatusCodes.Status401Unauthorized); - - group.MapGet("/{id:guid}/tracks", GetAlbumTracks) - .AddEndpointFilter>() - .WithName("GetAlbumTracks") - .WithSummary("Get the tracks of an album ordered by track number") - .Produces>() - .Produces(StatusCodes.Status404NotFound); - - group.MapGet("/{id:guid}/cover", GetAlbumCover) - .WithName("GetAlbumCover") - .WithSummary("Get an album cover image") - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status404NotFound); - - group.MapPost("/upload-picture", RequestAlbumPictureUpload) - .WithName("RequestAlbumPictureUpload") - .WithSummary("Request a pre-signed URL to upload an album picture") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces() - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); - - group.MapPost("/", CreateAlbum) - .WithName("CreateAlbum") - .WithSummary("Create a new album") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces(StatusCodes.Status201Created) - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); - - group.MapPut("/{id:guid}", UpdateAlbum) - .WithName("UpdateAlbum") - .WithSummary("Update an album") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces() - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound); - - group.MapDelete("/{id:guid}", DeleteAlbum) - .WithName("DeleteAlbum") - .WithSummary("Delete an album") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound); - - group.MapPost("/{id:guid}/tracks", AddAlbumTracks) - .WithName("AddAlbumTracks") - .WithSummary("Add your uploaded tracks to an album, skipping the ones it already has") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); - - group.MapDelete("/{id:guid}/tracks/{trackId:guid}", RemoveTrackFromAlbum) - .WithName("RemoveTrackFromAlbum") - .WithSummary("Remove a track from an album") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound); - - return app; - } - - private static async Task GetAlbums( - IMediator mediator, - string? title, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAlbumsQuery(title, page.PageNumber, page.PageSize), cancellationToken); - return Results.Ok(result); - } - - private static async Task GetAlbumById( - IMediator mediator, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAlbumByIdQuery(id), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task GetAlbumsByUserId( - IMediator mediator, - long userId, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, page.PageNumber, page.PageSize), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task GetRecentlyListenedAlbums( - IMediator mediator, - CurrentUser currentUser, - CancellationToken cancellationToken, - int limit = 12) - { - if (limit is < 1 or > MaxRecentAlbumsLimit) + public static IEndpointRouteBuilder MapAlbumEndpoints(this IEndpointRouteBuilder app) { - return Results.ValidationProblem(new Dictionary - { - [nameof(limit)] = [$"'{nameof(limit)}' must be between 1 and {MaxRecentAlbumsLimit}."] - }); + var group = app.MapGroup("/albums") + .WithTags("Albums"); + + group.MapGet("/", GetAlbums) + .AddEndpointFilter>() + .WithName("GetAlbums") + .WithSummary("Get paginated albums, optionally filtered by title") + .Produces>(); + + group.MapGet("/{id:guid}", GetAlbumById) + .WithName("GetAlbumById") + .WithSummary("Get an album by id") + .Produces() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/users/{userId:long}", GetAlbumsByUserId) + .AddEndpointFilter>() + .WithName("GetAlbumsByUserId") + .WithSummary("Get paginated albums for a user") + .Produces>() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/recent", GetRecentlyListenedAlbums) + .WithName("GetRecentlyListenedAlbums") + .WithSummary("Get the albums the current user listened to most recently") + .RequireAuthorization() + .Produces>() + .Produces(StatusCodes.Status401Unauthorized); + + group.MapGet("/{id:guid}/tracks", GetAlbumTracks) + .AddEndpointFilter>() + .WithName("GetAlbumTracks") + .WithSummary("Get the tracks of an album ordered by track number") + .Produces>() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/{id:guid}/cover", GetAlbumCover) + .WithName("GetAlbumCover") + .WithSummary("Get an album cover image") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + + group.MapPost("/upload-picture", RequestAlbumPictureUpload) + .WithName("RequestAlbumPictureUpload") + .WithSummary("Request a pre-signed URL to upload an album picture") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + group.MapPost("/", CreateAlbum) + .WithName("CreateAlbum") + .WithSummary("Create a new album") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + group.MapPut("/{id:guid}", UpdateAlbum) + .WithName("UpdateAlbum") + .WithSummary("Update an album") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + + group.MapDelete("/{id:guid}", DeleteAlbum) + .WithName("DeleteAlbum") + .WithSummary("Delete an album") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + + group.MapPost("/{id:guid}/tracks", AddAlbumTracks) + .WithName("AddAlbumTracks") + .WithSummary("Add your uploaded tracks to an album, skipping the ones it already has") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); + + group.MapDelete("/{id:guid}/tracks/{trackId:guid}", RemoveTrackFromAlbum) + .WithName("RemoveTrackFromAlbum") + .WithSummary("Remove a track from an album") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + + return app; } - var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken); - return Results.Ok(result); - } + private static async Task GetAlbums( + IMediator mediator, + string? title, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAlbumsQuery(title, page.PageNumber, page.PageSize), cancellationToken); + return Results.Ok(result); + } - private static async Task GetAlbumTracks( - IMediator mediator, - Guid id, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAlbumTracksQuery(id, page.PageNumber, page.PageSize), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetAlbumById( + IMediator mediator, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAlbumByIdQuery(id), cancellationToken); + return result.ToHttpResult(); + } - private static Task GetAlbumCover( - IMediator mediator, - IStorageService storageService, - HttpResponse response, - Guid id, - CancellationToken cancellationToken, - string size = "medium") - { - return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Album, id, size, cancellationToken); - } + private static async Task GetAlbumsByUserId( + IMediator mediator, + long userId, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, page.PageNumber, page.PageSize), cancellationToken); + return result.ToHttpResult(); + } - private static async Task RequestAlbumPictureUpload( - IMediator mediator, - CurrentUser currentUser, - PictureUploadRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RequestAlbumPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes), - cancellationToken); + private static async Task GetRecentlyListenedAlbums( + IMediator mediator, + CurrentUser currentUser, + CancellationToken cancellationToken, + int limit = 12) + { + if (limit is < 1 or > MaxRecentAlbumsLimit) + { + return Results.ValidationProblem(new Dictionary + { + [nameof(limit)] = [$"'{nameof(limit)}' must be between 1 and {MaxRecentAlbumsLimit}."] + }); + } - return result.ToHttpResult(); - } + var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken); + return Results.Ok(result); + } - private static async Task CreateAlbum( - IMediator mediator, - CurrentUser currentUser, - CreateAlbumRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), - cancellationToken); + private static async Task GetAlbumTracks( + IMediator mediator, + Guid id, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAlbumTracksQuery(id, page.PageNumber, page.PageSize), cancellationToken); + return result.ToHttpResult(); + } - return result.ToCreatedResult(album => $"/albums/{album.Id}"); - } + private static Task GetAlbumCover( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + Guid id, + CancellationToken cancellationToken, + string size = "medium") + { + return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Album, id, size, cancellationToken); + } - private static async Task UpdateAlbum( - IMediator mediator, - CurrentUser currentUser, - Guid id, - UpdateAlbumRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), - cancellationToken); + private static async Task RequestAlbumPictureUpload( + IMediator mediator, + CurrentUser currentUser, + PictureUploadRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RequestAlbumPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes), + cancellationToken); - return result.ToHttpResult(); - } + return result.ToHttpResult(); + } - private static async Task DeleteAlbum( - IMediator mediator, - CurrentUser currentUser, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new DeleteAlbumCommand(currentUser.RequiredId, id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task CreateAlbum( + IMediator mediator, + CurrentUser currentUser, + CreateAlbumRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), + cancellationToken); - private static async Task AddAlbumTracks( - IMediator mediator, - CurrentUser currentUser, - Guid id, - AddTracksRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new AddTracksToAlbumCommand(currentUser.RequiredId, id, request.TrackIds), - cancellationToken); + return result.ToCreatedResult(album => $"/albums/{album.Id}"); + } - return result.ToHttpResult(); - } + private static async Task UpdateAlbum( + IMediator mediator, + CurrentUser currentUser, + Guid id, + UpdateAlbumRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), + cancellationToken); - private static async Task RemoveTrackFromAlbum( - IMediator mediator, - CurrentUser currentUser, - Guid id, - Guid trackId, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RemoveTrackFromAlbumCommand(currentUser.RequiredId, id, trackId), - cancellationToken); + return result.ToHttpResult(); + } - return result.ToHttpResult(); + private static async Task DeleteAlbum( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new DeleteAlbumCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task AddAlbumTracks( + IMediator mediator, + CurrentUser currentUser, + Guid id, + AddTracksRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new AddTracksToAlbumCommand(currentUser.RequiredId, id, request.TrackIds), + cancellationToken); + + return result.ToHttpResult(); + } + + private static async Task RemoveTrackFromAlbum( + IMediator mediator, + CurrentUser currentUser, + Guid id, + Guid trackId, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RemoveTrackFromAlbumCommand(currentUser.RequiredId, id, trackId), + cancellationToken); + + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs index 517e79c..78f8474 100644 --- a/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs @@ -2,28 +2,29 @@ using Mediator; using Musify.Application.Configuration; using Musify.Application.Configuration.Responses; -namespace Musify.Api.Endpoints; - -public static class ConfigEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapConfigEndpoints(this IEndpointRouteBuilder app) + public static class ConfigEndpoints { - var group = app.MapGroup("/config") - .WithTags("Config"); + public static IEndpointRouteBuilder MapConfigEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/config") + .WithTags("Config"); - group.MapGet("/playback", GetPlaybackPublicConfig) - .WithName("GetPlaybackPublicConfig") - .WithSummary("Get the public playback configuration (whether anonymous listening is allowed). Works anonymously") - .Produces(); + group.MapGet("/playback", GetPlaybackPublicConfig) + .WithName("GetPlaybackPublicConfig") + .WithSummary("Get the public playback configuration (whether anonymous listening is allowed). Works anonymously") + .Produces(); - return app; - } + return app; + } - private static async Task GetPlaybackPublicConfig( - IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetPlaybackPublicConfigQuery(), cancellationToken); - return Results.Ok(result); + private static async Task GetPlaybackPublicConfig( + IMediator mediator, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetPlaybackPublicConfigQuery(), cancellationToken); + return Results.Ok(result); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs b/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs index 7bf3570..474a699 100644 --- a/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs +++ b/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs @@ -3,29 +3,30 @@ using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Pictures; -namespace Musify.Api.Endpoints; - -internal static class CoverEndpoint +namespace Musify.Api.Endpoints { - public static async Task Stream( - IMediator mediator, - IStorageService storageService, - HttpResponse response, - CoverOwner owner, - Guid id, - string size, - CancellationToken cancellationToken) + internal static class CoverEndpoint { - var result = await mediator.Send(new GetCoverQuery(owner, id, PictureSizeParser.Parse(size)), cancellationToken); - if (result.IsError) - return Results.NotFound(); + public static async Task Stream( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + CoverOwner owner, + Guid id, + string size, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetCoverQuery(owner, id, PictureSizeParser.Parse(size)), cancellationToken); + if (result.IsError) + return Results.NotFound(); - var location = result.Value; - var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); - if (stream == null) - return Results.NotFound(); + var location = result.Value; + var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); + if (stream == null) + return Results.NotFound(); - response.Headers.CacheControl = "public, max-age=31536000, immutable"; - return Results.Stream(stream, location.ContentType); + response.Headers.CacheControl = "public, max-age=31536000, immutable"; + return Results.Stream(stream, location.ContentType); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs index d779c88..3b99a5f 100644 --- a/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs @@ -2,41 +2,42 @@ using Mediator; using Musify.Application.Genres; using Musify.Application.Genres.Responses; -namespace Musify.Api.Endpoints; - -public static class GenreEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app) + public static class GenreEndpoints { - var group = app.MapGroup("/genres") - .WithTags("Genres"); + public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/genres") + .WithTags("Genres"); - group.MapGet("/", GetGenres) - .WithName("GetGenres") - .WithSummary("Get the genres that have at least one track, most populated first. Works anonymously") - .Produces>(); + group.MapGet("/", GetGenres) + .WithName("GetGenres") + .WithSummary("Get the genres that have at least one track, most populated first. Works anonymously") + .Produces>(); - group.MapGet("/available", GetAvailableGenres) - .WithName("GetAvailableGenres") - .WithSummary("Get every genre a track can be tagged with, including the genres it cannot be combined with. Works anonymously") - .Produces>(); + group.MapGet("/available", GetAvailableGenres) + .WithName("GetAvailableGenres") + .WithSummary("Get every genre a track can be tagged with, including the genres it cannot be combined with. Works anonymously") + .Produces>(); - return app; - } + return app; + } - private static async Task GetAvailableGenres( - IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAvailableGenresQuery(), cancellationToken); - return Results.Ok(result); - } + private static async Task GetAvailableGenres( + IMediator mediator, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAvailableGenresQuery(), cancellationToken); + return Results.Ok(result); + } - private static async Task GetGenres( - IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetGenresQuery(), cancellationToken); - return Results.Ok(result); + private static async Task GetGenres( + IMediator mediator, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetGenresQuery(), cancellationToken); + return Results.Ok(result); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs index af30182..f2c1a9c 100644 --- a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs @@ -8,53 +8,54 @@ using Musify.Application.Likes; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -namespace Musify.Api.Endpoints; - -public static class LikeEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapLikeEndpoints(this IEndpointRouteBuilder app) + public static class LikeEndpoints { - var group = app.MapGroup("/likes") - .WithTags("Likes") - .RequireAuthorization(); + public static IEndpointRouteBuilder MapLikeEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/likes") + .WithTags("Likes") + .RequireAuthorization(); - group.MapGet("/", GetLikedTracks) - .AddEndpointFilter>() - .WithName("GetLikedTracks") - .WithSummary("Get the current user's liked tracks") - .Produces>() - .Produces(StatusCodes.Status401Unauthorized); + group.MapGet("/", GetLikedTracks) + .AddEndpointFilter>() + .WithName("GetLikedTracks") + .WithSummary("Get the current user's liked tracks") + .Produces>() + .Produces(StatusCodes.Status401Unauthorized); - group.MapPost("/toggle", ToggleTrackLike) - .WithName("ToggleTrackLike") - .WithSummary("Like or unlike a track by id") - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapPost("/toggle", ToggleTrackLike) + .WithName("ToggleTrackLike") + .WithSummary("Like or unlike a track by id") + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - return app; - } + return app; + } - private static async Task GetLikedTracks( - IMediator mediator, - CurrentUser currentUser, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken); - return Results.Ok(result); - } + private static async Task GetLikedTracks( + IMediator mediator, + CurrentUser currentUser, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken); + return Results.Ok(result); + } - private static async Task ToggleTrackLike( - IMediator mediator, - CurrentUser currentUser, - ToggleLikeRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId), - cancellationToken); + private static async Task ToggleTrackLike( + IMediator mediator, + CurrentUser currentUser, + ToggleLikeRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId), + cancellationToken); - return result.ToHttpResult(); + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs index b0b0736..1924e2c 100644 --- a/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs @@ -4,48 +4,49 @@ using Musify.Api.Extensions; using Musify.Application.Mixes; using Musify.Application.Mixes.Responses; -namespace Musify.Api.Endpoints; - -public static class MixEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapMixEndpoints(this IEndpointRouteBuilder app) + public static class MixEndpoints { - var group = app.MapGroup("/mixes") - .WithTags("Mixes") - .RequireAuthorization(); + public static IEndpointRouteBuilder MapMixEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/mixes") + .WithTags("Mixes") + .RequireAuthorization(); - group.MapGet("/", GetMixes) - .WithName("GetMixes") - .WithSummary("Get the server-generated mixes of the current user") - .Produces>() - .Produces(StatusCodes.Status401Unauthorized); + group.MapGet("/", GetMixes) + .WithName("GetMixes") + .WithSummary("Get the server-generated mixes of the current user") + .Produces>() + .Produces(StatusCodes.Status401Unauthorized); - group.MapGet("/{id:guid}", GetMixById) - .WithName("GetMixById") - .WithSummary("Get a mix with all of its songs") - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}", GetMixById) + .WithName("GetMixById") + .WithSummary("Get a mix with all of its songs") + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - return app; - } + return app; + } - private static async Task GetMixes( - IMediator mediator, - CurrentUser currentUser, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetMixesByUserIdQuery(currentUser.RequiredId), cancellationToken); - return Results.Ok(result); - } + private static async Task GetMixes( + IMediator mediator, + CurrentUser currentUser, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMixesByUserIdQuery(currentUser.RequiredId), cancellationToken); + return Results.Ok(result); + } - private static async Task GetMixById( - IMediator mediator, - CurrentUser currentUser, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetMixByIdQuery(currentUser.RequiredId, id), cancellationToken); - return result.ToHttpResult(); + private static async Task GetMixById( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMixByIdQuery(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index d21d8b4..dd14e7f 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -13,223 +13,224 @@ using Musify.Application.PlayLists.Responses; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -namespace Musify.Api.Endpoints; - -public static class PlayListEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app) + public static class PlayListEndpoints { - var group = app.MapGroup("/playlists") - .WithTags("PlayLists"); + public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/playlists") + .WithTags("PlayLists"); - group.MapGet("/{id:guid}", GetPlayListById) - .WithName("GetPlayListById") - .WithSummary("Get a playlist by id") - .Produces() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}", GetPlayListById) + .WithName("GetPlayListById") + .WithSummary("Get a playlist by id") + .Produces() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/users/{userId:long}", GetPlayListsByUserId) - .AddEndpointFilter>() - .WithName("GetPlayListsByUserId") - .WithSummary("Get paginated playlists for a user. Private playlists are only returned to their owner") - .Produces>(); + group.MapGet("/users/{userId:long}", GetPlayListsByUserId) + .AddEndpointFilter>() + .WithName("GetPlayListsByUserId") + .WithSummary("Get paginated playlists for a user. Private playlists are only returned to their owner") + .Produces>(); - group.MapGet("/{id:guid}/tracks", GetPlayListTracks) - .AddEndpointFilter>() - .WithName("GetPlayListTracks") - .WithSummary("Get paginated tracks of a playlist") - .Produces>() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}/tracks", GetPlayListTracks) + .AddEndpointFilter>() + .WithName("GetPlayListTracks") + .WithSummary("Get paginated tracks of a playlist") + .Produces>() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id:guid}/cover", GetPlayListCover) - .WithName("GetPlayListCover") - .WithSummary("Get a playlist cover image") - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}/cover", GetPlayListCover) + .WithName("GetPlayListCover") + .WithSummary("Get a playlist cover image") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); - group.MapPost("/", CreatePlayList) - .WithName("CreatePlayList") - .WithSummary("Create a new playlist") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces(StatusCodes.Status201Created) - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapPost("/", CreatePlayList) + .WithName("CreatePlayList") + .WithSummary("Create a new playlist") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - group.MapPut("/{id:guid}", UpdatePlayList) - .WithName("UpdatePlayList") - .WithSummary("Update an existing playlist") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces() - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound); + group.MapPut("/{id:guid}", UpdatePlayList) + .WithName("UpdatePlayList") + .WithSummary("Update an existing playlist") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); - group.MapDelete("/{id:guid}", DeletePlayList) - .WithName("DeletePlayList") - .WithSummary("Delete a playlist") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound); + group.MapDelete("/{id:guid}", DeletePlayList) + .WithName("DeletePlayList") + .WithSummary("Delete a playlist") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); - group.MapPost("/upload-picture", RequestPlayListPictureUpload) - .WithName("RequestPlayListPictureUpload") - .WithSummary("Request a pre-signed URL to upload a playlist picture") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces() - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapPost("/upload-picture", RequestPlayListPictureUpload) + .WithName("RequestPlayListPictureUpload") + .WithSummary("Request a pre-signed URL to upload a playlist picture") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - group.MapPost("/{id:guid}/tracks", AddPlayListTracks) - .WithName("AddPlayListTracks") - .WithSummary("Add tracks to a playlist, skipping the ones it already has") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); + group.MapPost("/{id:guid}/tracks", AddPlayListTracks) + .WithName("AddPlayListTracks") + .WithSummary("Add tracks to a playlist, skipping the ones it already has") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); - group.MapDelete("/{id:guid}/tracks/{trackId:guid}", RemoveTrackFromPlayList) - .WithName("RemoveTrackFromPlayList") - .WithSummary("Remove a track from a playlist") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound); + group.MapDelete("/{id:guid}/tracks/{trackId:guid}", RemoveTrackFromPlayList) + .WithName("RemoveTrackFromPlayList") + .WithSummary("Remove a track from a playlist") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); - return app; - } + return app; + } - private static async Task GetPlayListById( - IMediator mediator, - CurrentUser currentUser, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetPlayListByIdQuery(id, currentUser.Id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetPlayListById( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetPlayListByIdQuery(id, currentUser.Id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetPlayListsByUserId( - IMediator mediator, - CurrentUser currentUser, - long userId, - string? name, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); - return Results.Ok(result); - } + private static async Task GetPlayListsByUserId( + IMediator mediator, + CurrentUser currentUser, + long userId, + string? name, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); + return Results.Ok(result); + } - private static async Task GetPlayListTracks( - IMediator mediator, - CurrentUser currentUser, - Guid id, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetPlayListTracksQuery(id, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetPlayListTracks( + IMediator mediator, + CurrentUser currentUser, + Guid id, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetPlayListTracksQuery(id, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); + return result.ToHttpResult(); + } - private static Task GetPlayListCover( - IMediator mediator, - IStorageService storageService, - HttpResponse response, - Guid id, - CancellationToken cancellationToken, - string size = "medium") - { - return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.PlayList, id, size, cancellationToken); - } + private static Task GetPlayListCover( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + Guid id, + CancellationToken cancellationToken, + string size = "medium") + { + return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.PlayList, id, size, cancellationToken); + } - private static async Task CreatePlayList( - IMediator mediator, - CurrentUser currentUser, - CreatePlayListRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId, request.Visibility), - cancellationToken); + private static async Task CreatePlayList( + IMediator mediator, + CurrentUser currentUser, + CreatePlayListRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId, request.Visibility), + cancellationToken); - return result.ToCreatedResult(playList => $"/playlists/{playList.Id}"); - } + return result.ToCreatedResult(playList => $"/playlists/{playList.Id}"); + } - private static async Task UpdatePlayList( - IMediator mediator, - CurrentUser currentUser, - Guid id, - UpdatePlayListRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new UpdatePlayListCommand(currentUser.RequiredId, id, request.Name, request.Description, request.PictureIntentId, request.Visibility), - cancellationToken); + private static async Task UpdatePlayList( + IMediator mediator, + CurrentUser currentUser, + Guid id, + UpdatePlayListRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new UpdatePlayListCommand(currentUser.RequiredId, id, request.Name, request.Description, request.PictureIntentId, request.Visibility), + cancellationToken); - return result.ToHttpResult(); - } + return result.ToHttpResult(); + } - private static async Task DeletePlayList( - IMediator mediator, - CurrentUser currentUser, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new DeletePlayListCommand(currentUser.RequiredId, id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task DeletePlayList( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new DeletePlayListCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task RequestPlayListPictureUpload( - IMediator mediator, - CurrentUser currentUser, - PictureUploadRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RequestPlayListPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes), - cancellationToken); + private static async Task RequestPlayListPictureUpload( + IMediator mediator, + CurrentUser currentUser, + PictureUploadRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RequestPlayListPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes), + cancellationToken); - return result.ToHttpResult(); - } + return result.ToHttpResult(); + } - private static async Task AddPlayListTracks( - IMediator mediator, - CurrentUser currentUser, - Guid id, - AddTracksRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new AddTracksToPlayListCommand(currentUser.RequiredId, id, request.TrackIds), - cancellationToken); + private static async Task AddPlayListTracks( + IMediator mediator, + CurrentUser currentUser, + Guid id, + AddTracksRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new AddTracksToPlayListCommand(currentUser.RequiredId, id, request.TrackIds), + cancellationToken); - return result.ToHttpResult(); - } + return result.ToHttpResult(); + } - private static async Task RemoveTrackFromPlayList( - IMediator mediator, - CurrentUser currentUser, - Guid id, - Guid trackId, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RemoveTrackFromPlayListCommand(currentUser.RequiredId, id, trackId), - cancellationToken); + private static async Task RemoveTrackFromPlayList( + IMediator mediator, + CurrentUser currentUser, + Guid id, + Guid trackId, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RemoveTrackFromPlayListCommand(currentUser.RequiredId, id, trackId), + cancellationToken); - return result.ToHttpResult(); + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index c8ee6e1..7a15b54 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -10,196 +10,197 @@ using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; -namespace Musify.Api.Endpoints; - -public static class TrackEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapTrackEndpoints(this IEndpointRouteBuilder app) + public static class TrackEndpoints { - var group = app.MapGroup("/tracks") - .WithTags("Tracks"); + public static IEndpointRouteBuilder MapTrackEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/tracks") + .WithTags("Tracks"); - group.MapGet("/", GetTracks) - .AddEndpointFilter>() - .WithName("GetTracks") - .WithSummary("Get paginated tracks, optionally filtered by name and genre") - .Produces>() - .ProducesValidationProblem(); + group.MapGet("/", GetTracks) + .AddEndpointFilter>() + .WithName("GetTracks") + .WithSummary("Get paginated tracks, optionally filtered by name and genre") + .Produces>() + .ProducesValidationProblem(); - group.MapGet("/{id:guid}", GetTrackById) - .WithName("GetTrackById") - .WithSummary("Get a track by id") - .Produces() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}", GetTrackById) + .WithName("GetTrackById") + .WithSummary("Get a track by id") + .Produces() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id:guid}/cover", GetTrackCover) - .WithName("GetTrackCover") - .WithSummary("Get a track cover image") - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}/cover", GetTrackCover) + .WithName("GetTrackCover") + .WithSummary("Get a track cover image") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id:guid}/stream", GetTrackStream) - .WithName("GetTrackStream") - .WithSummary("Get a streaming manifest URL and ticket for a track. Works anonymously when the playback configuration allows it") - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}/stream", GetTrackStream) + .WithName("GetTrackStream") + .WithSummary("Get a streaming manifest URL and ticket for a track. Works anonymously when the playback configuration allows it") + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - group.MapPut("/listens/{listenId:guid}/progress", RecordListeningProgress) - .AddEndpointFilter>() - .WithName("RecordListeningProgress") - .WithSummary("Report the total seconds actually played for a listen started by the stream endpoint") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapPut("/listens/{listenId:guid}/progress", RecordListeningProgress) + .AddEndpointFilter>() + .WithName("RecordListeningProgress") + .WithSummary("Report the total seconds actually played for a listen started by the stream endpoint") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/users/{userId:long}", GetTracksByUserId) - .AddEndpointFilter>() - .WithName("GetTracksByUserId") - .WithSummary("Get paginated tracks for a user") - .Produces>(); + group.MapGet("/users/{userId:long}", GetTracksByUserId) + .AddEndpointFilter>() + .WithName("GetTracksByUserId") + .WithSummary("Get paginated tracks for a user") + .Produces>(); - group.MapPost("/", CreateTrack) - .WithName("CreateTrack") - .WithSummary("Create a new track") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces(StatusCodes.Status201Created) - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); + group.MapPost("/", CreateTrack) + .WithName("CreateTrack") + .WithSummary("Create a new track") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); - group.MapDelete("/{id:guid}", DeleteTrack) - .WithName("DeleteTrack") - .WithSummary("Delete a track") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); + group.MapDelete("/{id:guid}", DeleteTrack) + .WithName("DeleteTrack") + .WithSummary("Delete a track") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); - group.MapPost("/upload-urls", RequestTrackUploadUrls) - .WithName("RequestTrackUploadUrls") - .WithSummary("Request pre-signed URLs to upload track picture and audio") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces() - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); + group.MapPost("/upload-urls", RequestTrackUploadUrls) + .WithName("RequestTrackUploadUrls") + .WithSummary("Request pre-signed URLs to upload track picture and audio") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); - return app; - } + return app; + } - private static async Task GetTracks( - IMediator mediator, - string? name, - GenreParameter? genre, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, genre?.Value), cancellationToken); - return Results.Ok(result); - } + private static async Task GetTracks( + IMediator mediator, + string? name, + GenreParameter? genre, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, genre?.Value), cancellationToken); + return Results.Ok(result); + } - private static async Task GetTrackById( - IMediator mediator, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetTrackByIdQuery(id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetTrackById( + IMediator mediator, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTrackByIdQuery(id), cancellationToken); + return result.ToHttpResult(); + } - private static Task GetTrackCover( - IMediator mediator, - IStorageService storageService, - HttpResponse response, - Guid id, - CancellationToken cancellationToken, - string size = "medium") - { - return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Track, id, size, cancellationToken); - } + private static Task GetTrackCover( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + Guid id, + CancellationToken cancellationToken, + string size = "medium") + { + return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Track, id, size, cancellationToken); + } - private static async Task GetTrackStream( - IMediator mediator, - CurrentUser currentUser, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new StartListeningCommand(id, currentUser.Id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetTrackStream( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new StartListeningCommand(id, currentUser.Id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task RecordListeningProgress( - IMediator mediator, - CurrentUser currentUser, - Guid listenId, - RecordListeningProgressRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RecordListeningProgressCommand(currentUser.RequiredId, listenId, request.PlayedSeconds), - cancellationToken); - return result.ToHttpResult(); - } + private static async Task RecordListeningProgress( + IMediator mediator, + CurrentUser currentUser, + Guid listenId, + RecordListeningProgressRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RecordListeningProgressCommand(currentUser.RequiredId, listenId, request.PlayedSeconds), + cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetTracksByUserId( - IMediator mediator, - long userId, - string? name, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetTracksByUserId( + IMediator mediator, + long userId, + string? name, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize), cancellationToken); + return result.ToHttpResult(); + } - private static async Task CreateTrack( - IMediator mediator, - CurrentUser currentUser, - CreateTrackRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags, request.IsExplicit), - cancellationToken); + private static async Task CreateTrack( + IMediator mediator, + CurrentUser currentUser, + CreateTrackRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags, request.IsExplicit), + cancellationToken); - return result.ToCreatedResult(track => $"/tracks/{track.Id}"); - } + return result.ToCreatedResult(track => $"/tracks/{track.Id}"); + } - private static async Task DeleteTrack( - IMediator mediator, - CurrentUser currentUser, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new DeleteTrackCommand(currentUser.RequiredId, id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task DeleteTrack( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new DeleteTrackCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task RequestTrackUploadUrls( - IMediator mediator, - CurrentUser currentUser, - RequestTrackUploadUrlsRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RequestTrackUploadUrlsCommand( - currentUser.RequiredId, - request.PictureFileType, - request.PictureContentType, - request.AudioFileType, - request.AudioContentType, - request.ExpectedPictureSizeBytes, - request.ExpectedAudioSizeBytes), - cancellationToken); + private static async Task RequestTrackUploadUrls( + IMediator mediator, + CurrentUser currentUser, + RequestTrackUploadUrlsRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RequestTrackUploadUrlsCommand( + currentUser.RequiredId, + request.PictureFileType, + request.PictureContentType, + request.AudioFileType, + request.AudioContentType, + request.ExpectedPictureSizeBytes, + request.ExpectedAudioSizeBytes), + cancellationToken); - return result.ToHttpResult(); + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs index 1c6815e..bbd8e72 100644 --- a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -9,204 +9,205 @@ using Musify.Application.Tracks.Responses; using Musify.Application.Users; using Musify.Application.Users.Responses; -namespace Musify.Api.Endpoints; - -public static class UserEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app) + public static class UserEndpoints { - var group = app.MapGroup("/users") - .WithTags("Users"); + public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/users") + .WithTags("Users"); - group.MapGet("/", GetUsers) - .AddEndpointFilter>() - .WithName("GetUsers") - .WithSummary("Get paginated users") - .Produces>(); + group.MapGet("/", GetUsers) + .AddEndpointFilter>() + .WithName("GetUsers") + .WithSummary("Get paginated users") + .Produces>(); - group.MapGet("/{id:long}", GetUserById) - .WithName("GetUserById") - .WithSummary("Get a user by id") - .Produces() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:long}", GetUserById) + .WithName("GetUserById") + .WithSummary("Get a user by id") + .Produces() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id:long}/listening-history", GetListeningHistory) - .WithName("GetListeningHistory") - .WithSummary("Get a user's listening history") - .Produces>() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:long}/listening-history", GetListeningHistory) + .WithName("GetListeningHistory") + .WithSummary("Get a user's listening history") + .Produces>() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id:long}/listening-stats", GetListeningStats) - .WithName("GetListeningStats") - .WithSummary("Get a user's listening stats for the current week and streak") - .RequireAuthorization() - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden); + group.MapGet("/{id:long}/listening-stats", GetListeningStats) + .WithName("GetListeningStats") + .WithSummary("Get a user's listening stats for the current week and streak") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); - group.MapGet("/{id:long}/last-listened-track", GetLastTrackListenedByUserId) - .WithName("GetLastTrackListenedByUserId") - .WithSummary("Get the last track a user listened to") - .Produces() - .Produces(StatusCodes.Status204NoContent); + group.MapGet("/{id:long}/last-listened-track", GetLastTrackListenedByUserId) + .WithName("GetLastTrackListenedByUserId") + .WithSummary("Get the last track a user listened to") + .Produces() + .Produces(StatusCodes.Status204NoContent); - group.MapGet("/{id:long}/profile", GetUserProfile) - .WithName("GetUserProfile") - .WithSummary("Get a user's public profile, including follower counts") - .Produces() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:long}/profile", GetUserProfile) + .WithName("GetUserProfile") + .WithSummary("Get a user's public profile, including follower counts") + .Produces() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id:long}/followers", GetFollowers) - .AddEndpointFilter>() - .WithName("GetUserFollowers") - .WithSummary("Get the users following a user. Visible to the user and to mutual followers") - .Produces>() - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:long}/followers", GetFollowers) + .AddEndpointFilter>() + .WithName("GetUserFollowers") + .WithSummary("Get the users following a user. Visible to the user and to mutual followers") + .Produces>() + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id:long}/following", GetFollowing) - .AddEndpointFilter>() - .WithName("GetUserFollowing") - .WithSummary("Get the users a user follows") - .Produces>() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:long}/following", GetFollowing) + .AddEndpointFilter>() + .WithName("GetUserFollowing") + .WithSummary("Get the users a user follows") + .Produces>() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id:long}/is-following", IsFollowing) - .WithName("IsFollowingUser") - .WithSummary("Check whether the current user follows another user") - .RequireAuthorization() - .Produces() - .Produces(StatusCodes.Status401Unauthorized); + group.MapGet("/{id:long}/is-following", IsFollowing) + .WithName("IsFollowingUser") + .WithSummary("Check whether the current user follows another user") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized); - group.MapPost("/{id:long}/follow", FollowUser) - .WithName("FollowUser") - .WithSummary("Follow a user") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .ProducesValidationProblem(); + group.MapPost("/{id:long}/follow", FollowUser) + .WithName("FollowUser") + .WithSummary("Follow a user") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .ProducesValidationProblem(); - group.MapDelete("/{id:long}/follow", UnfollowUser) - .WithName("UnfollowUser") - .WithSummary("Unfollow a user") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized); + group.MapDelete("/{id:long}/follow", UnfollowUser) + .WithName("UnfollowUser") + .WithSummary("Unfollow a user") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized); - return app; - } + return app; + } - private static async Task GetUsers( - IMediator mediator, - CurrentUser currentUser, - string? usernameSearch, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetUsersQuery(page.PageNumber, page.PageSize, usernameSearch, currentUser.Id), cancellationToken); - return Results.Ok(result); - } + private static async Task GetUsers( + IMediator mediator, + CurrentUser currentUser, + string? usernameSearch, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUsersQuery(page.PageNumber, page.PageSize, usernameSearch, currentUser.Id), cancellationToken); + return Results.Ok(result); + } - private static async Task GetUserById( - IMediator mediator, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetUserById( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetListeningHistory( - IMediator mediator, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken); - return Results.Ok(result); - } + private static async Task GetListeningHistory( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken); + return Results.Ok(result); + } - private static async Task GetListeningStats( - IMediator mediator, - CurrentUser currentUser, - long id, - CancellationToken cancellationToken) - { - if (currentUser.RequiredId != id) - return Results.Problem(statusCode: StatusCodes.Status403Forbidden); + private static async Task GetListeningStats( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + if (currentUser.RequiredId != id) + return Results.Problem(statusCode: StatusCodes.Status403Forbidden); - var result = await mediator.Send(new GetListeningStatsQuery(id), cancellationToken); - return Results.Ok(result); - } + var result = await mediator.Send(new GetListeningStatsQuery(id), cancellationToken); + return Results.Ok(result); + } - private static async Task GetLastTrackListenedByUserId( - IMediator mediator, - long id, - CancellationToken cancellationToken) - { - var track = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken); - return track == null ? Results.NoContent() : Results.Ok(track); - } + private static async Task GetLastTrackListenedByUserId( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + var track = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken); + return track == null ? Results.NoContent() : Results.Ok(track); + } - private static async Task GetUserProfile( - IMediator mediator, - CurrentUser currentUser, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetUserProfileQuery(id, currentUser.Id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetUserProfile( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserProfileQuery(id, currentUser.Id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetFollowers( - IMediator mediator, - CurrentUser currentUser, - long id, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetFollowers( + IMediator mediator, + CurrentUser currentUser, + long id, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetFollowing( - IMediator mediator, - CurrentUser currentUser, - long id, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetFollowing( + IMediator mediator, + CurrentUser currentUser, + long id, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken); + return result.ToHttpResult(); + } - private static async Task IsFollowing( - IMediator mediator, - CurrentUser currentUser, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new IsFollowingUserQuery(currentUser.RequiredId, id), cancellationToken); - return Results.Ok(result); - } + private static async Task IsFollowing( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new IsFollowingUserQuery(currentUser.RequiredId, id), cancellationToken); + return Results.Ok(result); + } - private static async Task FollowUser( - IMediator mediator, - CurrentUser currentUser, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new FollowUserCommand(currentUser.RequiredId, id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task FollowUser( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new FollowUserCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task UnfollowUser( - IMediator mediator, - CurrentUser currentUser, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new UnfollowUserCommand(currentUser.RequiredId, id), cancellationToken); - return result.ToHttpResult(); + private static async Task UnfollowUser( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new UnfollowUserCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs b/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs index d6e75ff..4e5d2f2 100644 --- a/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs +++ b/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs @@ -1,43 +1,44 @@ using ErrorOr; using IResult = Microsoft.AspNetCore.Http.IResult; -namespace Musify.Api.Extensions; - -public static class ErrorOrHttpExtensions +namespace Musify.Api.Extensions { - public static IResult ToHttpResult(this ErrorOr result) + public static class ErrorOrHttpExtensions { - if (result.IsError) - return Problem(result.Errors); - - return result.Value is Success - ? Results.NoContent() - : Results.Ok(result.Value); - } - - public static IResult ToCreatedResult(this ErrorOr result, Func locationFactory) => - result.IsError - ? Problem(result.Errors) - : Results.Created(locationFactory(result.Value), result.Value); - - private static IResult Problem(List errors) - { - if (errors.All(e => e.Type == ErrorType.Validation)) - return Results.ValidationProblem(ToValidationDictionary(errors)); - - var error = errors[0]; - return error.Type switch + public static IResult ToHttpResult(this ErrorOr result) { - ErrorType.NotFound => Results.Problem(error.Description, statusCode: StatusCodes.Status404NotFound), - ErrorType.Unauthorized => Results.Unauthorized(), - ErrorType.Forbidden => Results.Problem(error.Description, statusCode: StatusCodes.Status403Forbidden), - ErrorType.Conflict => Results.Problem(error.Description, statusCode: StatusCodes.Status409Conflict), - _ => Results.Problem(error.Description) - }; - } + if (result.IsError) + return Problem(result.Errors); - private static Dictionary ToValidationDictionary(IEnumerable errors) => - errors - .GroupBy(e => string.IsNullOrEmpty(e.Code) ? "general" : e.Code) - .ToDictionary(g => g.Key, g => g.Select(e => e.Description).ToArray()); + return result.Value is Success + ? Results.NoContent() + : Results.Ok(result.Value); + } + + public static IResult ToCreatedResult(this ErrorOr result, Func locationFactory) => + result.IsError + ? Problem(result.Errors) + : Results.Created(locationFactory(result.Value), result.Value); + + private static IResult Problem(List errors) + { + if (errors.All(e => e.Type == ErrorType.Validation)) + return Results.ValidationProblem(ToValidationDictionary(errors)); + + var error = errors[0]; + return error.Type switch + { + ErrorType.NotFound => Results.Problem(error.Description, statusCode: StatusCodes.Status404NotFound), + ErrorType.Unauthorized => Results.Unauthorized(), + ErrorType.Forbidden => Results.Problem(error.Description, statusCode: StatusCodes.Status403Forbidden), + ErrorType.Conflict => Results.Problem(error.Description, statusCode: StatusCodes.Status409Conflict), + _ => Results.Problem(error.Description) + }; + } + + private static Dictionary ToValidationDictionary(IEnumerable errors) => + errors + .GroupBy(e => string.IsNullOrEmpty(e.Code) ? "general" : e.Code) + .ToDictionary(g => g.Key, g => g.Select(e => e.Description).ToArray()); + } } diff --git a/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs b/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs index da5c6d1..b5e0084 100644 --- a/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs @@ -8,22 +8,23 @@ using Musify.Infrastructure.MassTransit; using Musify.Infrastructure.Persistence; using Musify.Infrastructure.Services; -namespace Musify.Api.Extensions; - -public static class ServiceCollectionExtensions +namespace Musify.Api.Extensions { - public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration) + public static class ServiceCollectionExtensions { - services.AddApplication(configuration); - services.AddDatabase(configuration); - services.AddStorageService(configuration); - services.AddStreamTicketService(configuration); - services.AddMassTransitClient(configuration); - services.AddAuthenticationConfiguration(configuration); - services.AddOpenApiConfiguration(); - services.AddScalarConfiguration(); - services.AddValidatorsFromAssemblyContaining(); + public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddApplication(configuration); + services.AddDatabase(configuration); + services.AddStorageService(configuration); + services.AddStreamTicketService(configuration); + services.AddMassTransitClient(configuration); + services.AddAuthenticationConfiguration(configuration); + services.AddOpenApiConfiguration(); + services.AddScalarConfiguration(); + services.AddValidatorsFromAssemblyContaining(); - return services; + return services; + } } } diff --git a/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs b/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs index 04cadad..3aeb2bd 100644 --- a/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs +++ b/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs @@ -1,20 +1,21 @@ using FluentValidation; -namespace Musify.Api.Filters; - -public sealed class ValidationFilter(IValidator validator) : IEndpointFilter where T : class +namespace Musify.Api.Filters { - public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + public sealed class ValidationFilter(IValidator validator) : IEndpointFilter where T : class { - var argument = context.Arguments.OfType().FirstOrDefault(); + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var argument = context.Arguments.OfType().FirstOrDefault(); - if (argument == null) - return await next(context); + if (argument == null) + return await next(context); - var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted); + var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted); - return result.IsValid - ? await next(context) - : Results.ValidationProblem(result.ToDictionary()); + return result.IsValid + ? await next(context) + : Results.ValidationProblem(result.ToDictionary()); + } } } diff --git a/backend/Hosts/Musify.Api/Models/GenreParameter.cs b/backend/Hosts/Musify.Api/Models/GenreParameter.cs index 8539c14..e866882 100644 --- a/backend/Hosts/Musify.Api/Models/GenreParameter.cs +++ b/backend/Hosts/Musify.Api/Models/GenreParameter.cs @@ -1,19 +1,20 @@ using Musify.Domain.ValueObjects; -namespace Musify.Api.Models; - -/// Query-string binding for a genre name (case-insensitive); unknown names are rejected with a 400. -public readonly record struct GenreParameter(Genre Value) +namespace Musify.Api.Models { - public static bool TryParse(string? value, out GenreParameter result) + /// Query-string binding for a genre name (case-insensitive); unknown names are rejected with a 400. + public readonly record struct GenreParameter(Genre Value) { - if (Enum.TryParse(value, ignoreCase: true, out var genre) && Enum.IsDefined(genre)) + public static bool TryParse(string? value, out GenreParameter result) { - result = new GenreParameter(genre); - return true; - } + if (Enum.TryParse(value, ignoreCase: true, out var genre) && Enum.IsDefined(genre)) + { + result = new GenreParameter(genre); + return true; + } - result = default; - return false; + result = default; + return false; + } } } diff --git a/backend/Hosts/Musify.Api/Models/PageQuery.cs b/backend/Hosts/Musify.Api/Models/PageQuery.cs index 888a064..2c21750 100644 --- a/backend/Hosts/Musify.Api/Models/PageQuery.cs +++ b/backend/Hosts/Musify.Api/Models/PageQuery.cs @@ -1,9 +1,10 @@ using Microsoft.AspNetCore.Mvc; using Musify.Application.Shared; -namespace Musify.Api.Models; - -/// Query-string binding for , keeping the camelCase parameter names of the API. -public record PageQuery( - [property: FromQuery(Name = "pageNumber")] int PageNumber = 1, - [property: FromQuery(Name = "pageSize")] int PageSize = 20); +namespace Musify.Api.Models +{ + /// Query-string binding for , keeping the camelCase parameter names of the API. + public record PageQuery( + [property: FromQuery(Name = "pageNumber")] int PageNumber = 1, + [property: FromQuery(Name = "pageSize")] int PageSize = 20); +} diff --git a/backend/Hosts/Musify.Api/OpenApi/OpenApiExtensions.cs b/backend/Hosts/Musify.Api/OpenApi/OpenApiExtensions.cs index 6f00c38..8f1751c 100644 --- a/backend/Hosts/Musify.Api/OpenApi/OpenApiExtensions.cs +++ b/backend/Hosts/Musify.Api/OpenApi/OpenApiExtensions.cs @@ -1,12 +1,13 @@ -namespace Musify.Api.OpenApi; - -public static class OpenApiExtensions +namespace Musify.Api.OpenApi { - public static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services) + public static class OpenApiExtensions { - services.AddOpenApi(); - services.ConfigureOptions(); + public static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services) + { + services.AddOpenApi(); + services.ConfigureOptions(); - return services; + return services; + } } } diff --git a/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs index a1ae1ec..acc2c72 100644 --- a/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs @@ -4,88 +4,89 @@ using Microsoft.Extensions.Options; using Microsoft.OpenApi; using Musify.Api.Authentication; -namespace Musify.Api.OpenApi; - -public sealed class OpenApiOptionsSetup(IOptions options) - : IConfigureNamedOptions +namespace Musify.Api.OpenApi { - internal const string SecuritySchemeId = "OAuth2"; - - private readonly AuthenticationConfiguration configuration = options.Value; - - public void Configure(string? name, OpenApiOptions options) => Configure(options); - - public void Configure(OpenApiOptions options) + public sealed class OpenApiOptionsSetup(IOptions options) + : IConfigureNamedOptions { - options.AddDocumentTransformer((document, _, _) => + internal const string SecuritySchemeId = "OAuth2"; + + private readonly AuthenticationConfiguration configuration = options.Value; + + public void Configure(string? name, OpenApiOptions options) => Configure(options); + + public void Configure(OpenApiOptions options) { - document.Components ??= new OpenApiComponents(); - document.Components.SecuritySchemes ??= new Dictionary(); - document.Components.SecuritySchemes[SecuritySchemeId] = new OpenApiSecurityScheme + options.AddDocumentTransformer((document, _, _) => { - Type = SecuritySchemeType.OAuth2, - Flows = new OpenApiOAuthFlows + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes ??= new Dictionary(); + document.Components.SecuritySchemes[SecuritySchemeId] = new OpenApiSecurityScheme { - AuthorizationCode = new OpenApiOAuthFlow + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows { - AuthorizationUrl = new Uri(configuration.AuthorizationEndpoint), - TokenUrl = new Uri(configuration.TokenEndpoint), - Scopes = configuration.Scopes.ToDictionary(scope => scope, scope => scope), + AuthorizationCode = new OpenApiOAuthFlow + { + AuthorizationUrl = new Uri(configuration.AuthorizationEndpoint), + TokenUrl = new Uri(configuration.TokenEndpoint), + Scopes = configuration.Scopes.ToDictionary(scope => scope, scope => scope), + }, }, - }, - }; + }; - return Task.CompletedTask; - }); - - options.AddSchemaTransformer((schema, context, _) => - { - // Every long the API returns is a user id, serialized as a string (JS loses precision above 2^53). - // Request DTOs only carry byte sizes, which may be sent as numbers or strings. - if (context.JsonPropertyInfo is { } property - && (property.PropertyType == typeof(long) || property.PropertyType == typeof(long?))) - { - var isRequest = property.DeclaringType?.Name.EndsWith("Request", StringComparison.Ordinal) == true; - schema.Type = isRequest ? JsonSchemaType.Integer | JsonSchemaType.String : JsonSchemaType.String; - if (property.PropertyType == typeof(long?)) - schema.Type |= JsonSchemaType.Null; - schema.Format = isRequest ? "int64" : null; - } - - return Task.CompletedTask; - }); - - options.AddOperationTransformer((operation, context, _) => - { - // Route parameters constrained with :long are user ids, which travel as strings like every other long. - foreach (var parameter in operation.Parameters?.OfType() ?? []) - { - if (parameter.Schema is OpenApiSchema { Type: JsonSchemaType.Integer, Format: "int64" } schema) - { - schema.Type = JsonSchemaType.String; - schema.Format = null; - } - } - - var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata - .OfType() - .Any(); - - var allowsAnonymous = context.Description.ActionDescriptor.EndpointMetadata - .OfType() - .Any(); - - if (!requiresAuthorization || allowsAnonymous) return Task.CompletedTask; - - operation.Security ??= new List(); - operation.Security.Add(new OpenApiSecurityRequirement - { - [new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] = - configuration.Scopes.ToList(), }); - return Task.CompletedTask; - }); + options.AddSchemaTransformer((schema, context, _) => + { + // Every long the API returns is a user id, serialized as a string (JS loses precision above 2^53). + // Request DTOs only carry byte sizes, which may be sent as numbers or strings. + if (context.JsonPropertyInfo is { } property + && (property.PropertyType == typeof(long) || property.PropertyType == typeof(long?))) + { + var isRequest = property.DeclaringType?.Name.EndsWith("Request", StringComparison.Ordinal) == true; + schema.Type = isRequest ? JsonSchemaType.Integer | JsonSchemaType.String : JsonSchemaType.String; + if (property.PropertyType == typeof(long?)) + schema.Type |= JsonSchemaType.Null; + schema.Format = isRequest ? "int64" : null; + } + + return Task.CompletedTask; + }); + + options.AddOperationTransformer((operation, context, _) => + { + // Route parameters constrained with :long are user ids, which travel as strings like every other long. + foreach (var parameter in operation.Parameters?.OfType() ?? []) + { + if (parameter.Schema is OpenApiSchema { Type: JsonSchemaType.Integer, Format: "int64" } schema) + { + schema.Type = JsonSchemaType.String; + schema.Format = null; + } + } + + var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .Any(); + + var allowsAnonymous = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .Any(); + + if (!requiresAuthorization || allowsAnonymous) + return Task.CompletedTask; + + operation.Security ??= new List(); + operation.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] = + configuration.Scopes.ToList(), + }); + + return Task.CompletedTask; + }); + } } } diff --git a/backend/Hosts/Musify.Api/Scalar/ScalarExtensions.cs b/backend/Hosts/Musify.Api/Scalar/ScalarExtensions.cs index 13f2db8..f1aba32 100644 --- a/backend/Hosts/Musify.Api/Scalar/ScalarExtensions.cs +++ b/backend/Hosts/Musify.Api/Scalar/ScalarExtensions.cs @@ -1,11 +1,12 @@ -namespace Musify.Api.Scalar; - -public static class ScalarExtensions +namespace Musify.Api.Scalar { - public static IServiceCollection AddScalarConfiguration(this IServiceCollection services) + public static class ScalarExtensions { - services.ConfigureOptions(); + public static IServiceCollection AddScalarConfiguration(this IServiceCollection services) + { + services.ConfigureOptions(); - return services; + return services; + } } } diff --git a/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs b/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs index 05e77b6..6f33eef 100644 --- a/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs @@ -3,29 +3,30 @@ using Musify.Api.Authentication; using Musify.Api.OpenApi; using Scalar.AspNetCore; -namespace Musify.Api.Scalar; - -public sealed class ScalarOptionsSetup(IOptions options) - : IConfigureOptions +namespace Musify.Api.Scalar { - private readonly AuthenticationConfiguration configuration = options.Value; - - public void Configure(ScalarOptions scalarOptions) + public sealed class ScalarOptionsSetup(IOptions options) + : IConfigureOptions { - scalarOptions - .WithTitle("Musify API") - .AddPreferredSecuritySchemes(OpenApiOptionsSetup.SecuritySchemeId) - .AddAuthorizationCodeFlow(OpenApiOptionsSetup.SecuritySchemeId, flow => - { - flow.ClientId = configuration.ClientId; - flow.ClientSecret = configuration.ClientSecret; - flow.AuthorizationUrl = configuration.AuthorizationEndpoint; - flow.TokenUrl = configuration.TokenEndpoint; - flow.Pkce = Pkce.Sha256; - flow.SelectedScopes = configuration.Scopes; - flow.RedirectUri = configuration.ScalarRedirectUri; - flow.RefreshUrl = configuration.TokenEndpoint; - }) - .HideModels(); + private readonly AuthenticationConfiguration configuration = options.Value; + + public void Configure(ScalarOptions scalarOptions) + { + scalarOptions + .WithTitle("Musify API") + .AddPreferredSecuritySchemes(OpenApiOptionsSetup.SecuritySchemeId) + .AddAuthorizationCodeFlow(OpenApiOptionsSetup.SecuritySchemeId, flow => + { + flow.ClientId = configuration.ClientId; + flow.ClientSecret = configuration.ClientSecret; + flow.AuthorizationUrl = configuration.AuthorizationEndpoint; + flow.TokenUrl = configuration.TokenEndpoint; + flow.Pkce = Pkce.Sha256; + flow.SelectedScopes = configuration.Scopes; + flow.RedirectUri = configuration.ScalarRedirectUri; + flow.RefreshUrl = configuration.TokenEndpoint; + }) + .HideModels(); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs index d3466e5..92f8990 100644 --- a/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs @@ -1,20 +1,21 @@ using FluentValidation; using Musify.Api.DataTransferObjects; -namespace Musify.Api.Validators; - -public sealed class AddTracksRequestValidator : AbstractValidator +namespace Musify.Api.Validators { - public const int MaxTracksPerRequest = 500; - - public AddTracksRequestValidator() + public sealed class AddTracksRequestValidator : AbstractValidator { - RuleFor(x => x.TrackIds) - .NotEmpty() - .Must(ids => ids.Count <= MaxTracksPerRequest) - .WithMessage($"At most {MaxTracksPerRequest} tracks can be added at once."); + public const int MaxTracksPerRequest = 500; - RuleForEach(x => x.TrackIds) - .NotEmpty(); + public AddTracksRequestValidator() + { + RuleFor(x => x.TrackIds) + .NotEmpty() + .Must(ids => ids.Count <= MaxTracksPerRequest) + .WithMessage($"At most {MaxTracksPerRequest} tracks can be added at once."); + + RuleForEach(x => x.TrackIds) + .NotEmpty(); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs index 0bb90b2..f65887d 100644 --- a/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs @@ -1,24 +1,25 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Albums; -namespace Musify.Api.Validators.Albums; - -public sealed class CreateAlbumRequestValidator : AbstractValidator +namespace Musify.Api.Validators.Albums { - public CreateAlbumRequestValidator() + public sealed class CreateAlbumRequestValidator : AbstractValidator { - RuleFor(x => x.Title) - .NotEmpty() - .MaximumLength(Limits.AlbumTitle); + public CreateAlbumRequestValidator() + { + RuleFor(x => x.Title) + .NotEmpty() + .MaximumLength(Limits.AlbumTitle); - RuleFor(x => x.Description) - .MaximumLength(Limits.Description); + RuleFor(x => x.Description) + .MaximumLength(Limits.Description); - RuleFor(x => x.ReleaseYear) - .InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear) - .When(x => x.ReleaseYear.HasValue); + RuleFor(x => x.ReleaseYear) + .InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear) + .When(x => x.ReleaseYear.HasValue); - RuleFor(x => x.PictureIntentId) - .NotEmpty(); + RuleFor(x => x.PictureIntentId) + .NotEmpty(); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs index 91620c6..e9b6458 100644 --- a/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs @@ -1,21 +1,22 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Albums; -namespace Musify.Api.Validators.Albums; - -public sealed class UpdateAlbumRequestValidator : AbstractValidator +namespace Musify.Api.Validators.Albums { - public UpdateAlbumRequestValidator() + public sealed class UpdateAlbumRequestValidator : AbstractValidator { - RuleFor(x => x.Title) - .NotEmpty() - .MaximumLength(Limits.AlbumTitle); + public UpdateAlbumRequestValidator() + { + RuleFor(x => x.Title) + .NotEmpty() + .MaximumLength(Limits.AlbumTitle); - RuleFor(x => x.Description) - .MaximumLength(Limits.Description); + RuleFor(x => x.Description) + .MaximumLength(Limits.Description); - RuleFor(x => x.ReleaseYear) - .InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear) - .When(x => x.ReleaseYear.HasValue); + RuleFor(x => x.ReleaseYear) + .InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear) + .When(x => x.ReleaseYear.HasValue); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Limits.cs b/backend/Hosts/Musify.Api/Validators/Limits.cs index ac782d8..30f91fb 100644 --- a/backend/Hosts/Musify.Api/Validators/Limits.cs +++ b/backend/Hosts/Musify.Api/Validators/Limits.cs @@ -1,12 +1,13 @@ -namespace Musify.Api.Validators; - -public static class Limits +namespace Musify.Api.Validators { - public const int TrackTitle = 200; - public const int AlbumTitle = 200; - public const int PlayListName = 50; - public const int Description = 256; - public const int EarliestReleaseYear = 1877; + public static class Limits + { + public const int TrackTitle = 200; + public const int AlbumTitle = 200; + public const int PlayListName = 50; + public const int Description = 256; + public const int EarliestReleaseYear = 1877; - public static int LatestReleaseYear => DateTime.UtcNow.Year + 1; + public static int LatestReleaseYear => DateTime.UtcNow.Year + 1; + } } diff --git a/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs b/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs index 2ae656a..c144a7d 100644 --- a/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs @@ -2,16 +2,17 @@ using FluentValidation; using Musify.Api.Models; using Musify.Application.Shared; -namespace Musify.Api.Validators; - -public sealed class PageQueryValidator : AbstractValidator +namespace Musify.Api.Validators { - public PageQueryValidator() + public sealed class PageQueryValidator : AbstractValidator { - RuleFor(x => x.PageNumber) - .GreaterThanOrEqualTo(1); + public PageQueryValidator() + { + RuleFor(x => x.PageNumber) + .GreaterThanOrEqualTo(1); - RuleFor(x => x.PageSize) - .InclusiveBetween(1, PageRequest.MaxPageSize); + RuleFor(x => x.PageSize) + .InclusiveBetween(1, PageRequest.MaxPageSize); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/PictureUploadRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PictureUploadRequestValidator.cs index 15a9f40..d574a85 100644 --- a/backend/Hosts/Musify.Api/Validators/PictureUploadRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PictureUploadRequestValidator.cs @@ -2,19 +2,20 @@ using FluentValidation; using Musify.Api.DataTransferObjects; using Musify.Application.Configuration; -namespace Musify.Api.Validators; - -public sealed class PictureUploadRequestValidator : AbstractValidator +namespace Musify.Api.Validators { - public PictureUploadRequestValidator(UploadIntentConfiguration uploadConfiguration) + public sealed class PictureUploadRequestValidator : AbstractValidator { - RuleFor(x => x.FileType) - .MustBeFileType(Uploads.PictureFileTypes); + public PictureUploadRequestValidator(UploadIntentConfiguration uploadConfiguration) + { + RuleFor(x => x.FileType) + .MustBeFileType(Uploads.PictureFileTypes); - RuleFor(x => x.ContentType) - .MustBeContentType(Uploads.PictureContentTypes); + RuleFor(x => x.ContentType) + .MustBeContentType(Uploads.PictureContentTypes); - RuleFor(x => x.ExpectedSizeBytes) - .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + RuleFor(x => x.ExpectedSizeBytes) + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs index 7695b3c..f2d854c 100644 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs @@ -1,20 +1,21 @@ using FluentValidation; using Musify.Api.DataTransferObjects.PlayLists; -namespace Musify.Api.Validators.PlayLists; - -public sealed class CreatePlayListRequestValidator : AbstractValidator +namespace Musify.Api.Validators.PlayLists { - public CreatePlayListRequestValidator() + public sealed class CreatePlayListRequestValidator : AbstractValidator { - RuleFor(x => x.Name) - .NotEmpty() - .MaximumLength(Limits.PlayListName); + public CreatePlayListRequestValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(Limits.PlayListName); - RuleFor(x => x.Description) - .MaximumLength(Limits.Description); + RuleFor(x => x.Description) + .MaximumLength(Limits.Description); - RuleFor(x => x.Visibility) - .IsInEnum(); + RuleFor(x => x.Visibility) + .IsInEnum(); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs index 6ca36af..fd1116a 100644 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs @@ -1,23 +1,24 @@ using FluentValidation; using Musify.Api.DataTransferObjects.PlayLists; -namespace Musify.Api.Validators.PlayLists; - -public sealed class UpdatePlayListRequestValidator : AbstractValidator +namespace Musify.Api.Validators.PlayLists { - public UpdatePlayListRequestValidator() + public sealed class UpdatePlayListRequestValidator : AbstractValidator { - RuleFor(x => x.Name) - .NotEmpty() - .MaximumLength(Limits.PlayListName) - .When(x => x.Name != null); + public UpdatePlayListRequestValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(Limits.PlayListName) + .When(x => x.Name != null); - RuleFor(x => x.Description) - .MaximumLength(Limits.Description) - .When(x => x.Description != null); + RuleFor(x => x.Description) + .MaximumLength(Limits.Description) + .When(x => x.Description != null); - RuleFor(x => x.Visibility) - .IsInEnum() - .When(x => x.Visibility != null); + RuleFor(x => x.Visibility) + .IsInEnum() + .When(x => x.Visibility != null); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs index 266ba43..f9d9876 100644 --- a/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs @@ -2,33 +2,34 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Tracks; using Musify.Domain.ValueObjects; -namespace Musify.Api.Validators.Tracks; - -public sealed class CreateTrackRequestValidator : AbstractValidator +namespace Musify.Api.Validators.Tracks { - public CreateTrackRequestValidator() + public sealed class CreateTrackRequestValidator : AbstractValidator { - RuleFor(x => x.Title) - .NotEmpty() - .MaximumLength(Limits.TrackTitle); + public CreateTrackRequestValidator() + { + RuleFor(x => x.Title) + .NotEmpty() + .MaximumLength(Limits.TrackTitle); - RuleFor(x => x.PictureIntentId) - .NotEmpty(); + RuleFor(x => x.PictureIntentId) + .NotEmpty(); - RuleFor(x => x.AudioIntentId) - .NotEmpty(); + RuleFor(x => x.AudioIntentId) + .NotEmpty(); - RuleFor(x => x.Tags) - .NotEmpty() - .WithMessage("At least one tag is required."); + RuleFor(x => x.Tags) + .NotEmpty() + .WithMessage("At least one tag is required."); - RuleFor(x => x.Tags) - .Must(tags => GenreCompatibility.FindConflicts(tags).Count == 0) - .When(x => x.Tags is { Count: > 0 }) - .WithMessage(x => - { - var conflict = GenreCompatibility.FindConflicts(x.Tags).First(); - return $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible."; - }); + RuleFor(x => x.Tags) + .Must(tags => GenreCompatibility.FindConflicts(tags).Count == 0) + .When(x => x.Tags is { Count: > 0 }) + .WithMessage(x => + { + var conflict = GenreCompatibility.FindConflicts(x.Tags).First(); + return $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible."; + }); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs index 518be4b..96e152e 100644 --- a/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs @@ -1,14 +1,15 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Tracks; -namespace Musify.Api.Validators.Tracks; - -public sealed class RecordListeningProgressRequestValidator : AbstractValidator +namespace Musify.Api.Validators.Tracks { - public RecordListeningProgressRequestValidator() + public sealed class RecordListeningProgressRequestValidator : AbstractValidator { - RuleFor(x => x.PlayedSeconds) - .Must(seconds => double.IsFinite(seconds) && seconds >= 0) - .WithMessage("'Played Seconds' must be a non-negative number."); + public RecordListeningProgressRequestValidator() + { + RuleFor(x => x.PlayedSeconds) + .Must(seconds => double.IsFinite(seconds) && seconds >= 0) + .WithMessage("'Played Seconds' must be a non-negative number."); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs index ee2834e..3e78902 100644 --- a/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs @@ -2,28 +2,29 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Tracks; using Musify.Application.Configuration; -namespace Musify.Api.Validators.Tracks; - -public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator +namespace Musify.Api.Validators.Tracks { - public RequestTrackUploadUrlsRequestValidator(UploadIntentConfiguration uploadConfiguration) + public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator { - RuleFor(x => x.PictureFileType) - .MustBeFileType(Uploads.PictureFileTypes); + public RequestTrackUploadUrlsRequestValidator(UploadIntentConfiguration uploadConfiguration) + { + RuleFor(x => x.PictureFileType) + .MustBeFileType(Uploads.PictureFileTypes); - RuleFor(x => x.PictureContentType) - .MustBeContentType(Uploads.PictureContentTypes); + RuleFor(x => x.PictureContentType) + .MustBeContentType(Uploads.PictureContentTypes); - RuleFor(x => x.AudioFileType) - .MustBeFileType(Uploads.AudioFileTypes); + RuleFor(x => x.AudioFileType) + .MustBeFileType(Uploads.AudioFileTypes); - RuleFor(x => x.AudioContentType) - .MustBeContentType(Uploads.AudioContentTypes); + RuleFor(x => x.AudioContentType) + .MustBeContentType(Uploads.AudioContentTypes); - RuleFor(x => x.ExpectedPictureSizeBytes) - .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + RuleFor(x => x.ExpectedPictureSizeBytes) + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); - RuleFor(x => x.ExpectedAudioSizeBytes) - .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + RuleFor(x => x.ExpectedAudioSizeBytes) + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Uploads.cs b/backend/Hosts/Musify.Api/Validators/Uploads.cs index 39ad9fc..c5ca018 100644 --- a/backend/Hosts/Musify.Api/Validators/Uploads.cs +++ b/backend/Hosts/Musify.Api/Validators/Uploads.cs @@ -1,42 +1,43 @@ using FluentValidation; -namespace Musify.Api.Validators; - -public static class Uploads +namespace Musify.Api.Validators { - public static readonly IReadOnlySet PictureFileTypes = - new HashSet(StringComparer.OrdinalIgnoreCase) { "jpg", "jpeg", "png", "webp" }; + public static class Uploads + { + public static readonly IReadOnlySet PictureFileTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "jpg", "jpeg", "png", "webp" }; - public static readonly IReadOnlySet PictureContentTypes = - new HashSet(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp" }; + public static readonly IReadOnlySet PictureContentTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp" }; - public static readonly IReadOnlySet AudioFileTypes = - new HashSet(StringComparer.OrdinalIgnoreCase) { "mp3", "wav", "flac", "ogg", "m4a", "aac" }; + public static readonly IReadOnlySet AudioFileTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "mp3", "wav", "flac", "ogg", "m4a", "aac" }; - public static readonly IReadOnlySet AudioContentTypes = - new HashSet(StringComparer.OrdinalIgnoreCase) - { - "audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/wave", "audio/flac", "audio/x-flac", - "audio/ogg", "audio/mp4", "audio/x-m4a", "audio/m4a", "audio/aac" - }; + public static readonly IReadOnlySet AudioContentTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/wave", "audio/flac", "audio/x-flac", + "audio/ogg", "audio/mp4", "audio/x-m4a", "audio/m4a", "audio/aac" + }; - public static IRuleBuilderOptions MustBeFileType( - this IRuleBuilder rule, IReadOnlySet allowed) => - rule - .NotEmpty() - .Length(1, 10) - .Matches("^[A-Za-z0-9]+$").WithMessage("'{PropertyName}' must contain only letters and digits.") - .Must(allowed.Contains).WithMessage("'{PropertyName}' must be one of: " + string.Join(", ", allowed) + "."); + public static IRuleBuilderOptions MustBeFileType( + this IRuleBuilder rule, IReadOnlySet allowed) => + rule + .NotEmpty() + .Length(1, 10) + .Matches("^[A-Za-z0-9]+$").WithMessage("'{PropertyName}' must contain only letters and digits.") + .Must(allowed.Contains).WithMessage("'{PropertyName}' must be one of: " + string.Join(", ", allowed) + "."); - public static IRuleBuilderOptions MustBeContentType( - this IRuleBuilder rule, IReadOnlySet allowed) => - rule - .NotEmpty() - .Must(allowed.Contains).WithMessage("'{PropertyName}' is not an allowed content type."); + public static IRuleBuilderOptions MustBeContentType( + this IRuleBuilder rule, IReadOnlySet allowed) => + rule + .NotEmpty() + .Must(allowed.Contains).WithMessage("'{PropertyName}' is not an allowed content type."); - public static IRuleBuilderOptions MustBeValidUploadSize( - this IRuleBuilder rule, long maxUploadBytes) => - rule - .Must(size => size is null || (size > 0 && size <= maxUploadBytes)) - .WithMessage($"'{{PropertyName}}' must be greater than 0 and at most {maxUploadBytes} bytes."); + public static IRuleBuilderOptions MustBeValidUploadSize( + this IRuleBuilder rule, long maxUploadBytes) => + rule + .Must(size => size is null || (size > 0 && size <= maxUploadBytes)) + .WithMessage($"'{{PropertyName}}' must be greater than 0 and at most {maxUploadBytes} bytes."); + } } diff --git a/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs b/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs index 3a27213..d458ae0 100644 --- a/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs +++ b/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs @@ -3,58 +3,59 @@ using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using Musify.StreamingGateway.Configuration; -namespace Musify.StreamingGateway.Authentication; - -public sealed record TicketValidationResult(string Prefix, long? MaxBytes) +namespace Musify.StreamingGateway.Authentication { - public static readonly TicketValidationResult Invalid = new(string.Empty, null); - - public bool IsValid => Prefix.Length > 0; -} - -public sealed class TicketValidator : IDisposable -{ - private readonly RSA rsa; - private readonly TokenValidationParameters validationParameters; - private readonly JsonWebTokenHandler handler = new(); - - public TicketValidator(StreamTicketValidationConfiguration configuration) + public sealed record TicketValidationResult(string Prefix, long? MaxBytes) { - rsa = RSA.Create(); - rsa.ImportFromPem(File.ReadAllText(configuration.PublicKeyPath)); + public static readonly TicketValidationResult Invalid = new(string.Empty, null); - validationParameters = new TokenValidationParameters + public bool IsValid => Prefix.Length > 0; + } + + public sealed class TicketValidator : IDisposable + { + private readonly RSA rsa; + private readonly TokenValidationParameters validationParameters; + private readonly JsonWebTokenHandler handler = new(); + + public TicketValidator(StreamTicketValidationConfiguration configuration) { - ValidateIssuer = true, - ValidIssuer = configuration.Issuer, - ValidateAudience = true, - ValidAudience = configuration.Audience, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - IssuerSigningKey = new RsaSecurityKey(rsa), - ValidAlgorithms = [SecurityAlgorithms.RsaSha256], - ClockSkew = TimeSpan.FromSeconds(30), - }; + rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(configuration.PublicKeyPath)); + + validationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = configuration.Issuer, + ValidateAudience = true, + ValidAudience = configuration.Audience, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new RsaSecurityKey(rsa), + ValidAlgorithms = [SecurityAlgorithms.RsaSha256], + ClockSkew = TimeSpan.FromSeconds(30), + }; + } + + public async Task ValidateTicketAsync(string? token) + { + if (string.IsNullOrWhiteSpace(token)) + return TicketValidationResult.Invalid; + + var result = await handler.ValidateTokenAsync(token, validationParameters); + if (!result.IsValid) + return TicketValidationResult.Invalid; + + var prefix = result.ClaimsIdentity?.FindFirst("prefix")?.Value; + if (string.IsNullOrEmpty(prefix)) + return TicketValidationResult.Invalid; + + var maxBytesClaim = result.ClaimsIdentity?.FindFirst("maxBytes")?.Value; + var maxBytes = long.TryParse(maxBytesClaim, out var parsed) ? parsed : (long?)null; + + return new TicketValidationResult(prefix, maxBytes); + } + + public void Dispose() => rsa.Dispose(); } - - public async Task ValidateTicketAsync(string? token) - { - if (string.IsNullOrWhiteSpace(token)) - return TicketValidationResult.Invalid; - - var result = await handler.ValidateTokenAsync(token, validationParameters); - if (!result.IsValid) - return TicketValidationResult.Invalid; - - var prefix = result.ClaimsIdentity?.FindFirst("prefix")?.Value; - if (string.IsNullOrEmpty(prefix)) - return TicketValidationResult.Invalid; - - var maxBytesClaim = result.ClaimsIdentity?.FindFirst("maxBytes")?.Value; - var maxBytes = long.TryParse(maxBytesClaim, out var parsed) ? parsed : (long?)null; - - return new TicketValidationResult(prefix, maxBytes); - } - - public void Dispose() => rsa.Dispose(); } diff --git a/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationConfiguration.cs b/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationConfiguration.cs index 3d9a0ff..927e7f4 100644 --- a/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationConfiguration.cs +++ b/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationConfiguration.cs @@ -1,23 +1,24 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.StreamingGateway.Configuration; - -public sealed class StreamTicketValidationConfiguration +namespace Musify.StreamingGateway.Configuration { - public const string SectionName = "StreamTicket"; + public sealed class StreamTicketValidationConfiguration + { + public const string SectionName = "StreamTicket"; - [Required] - public required string PublicKeyPath { get; set; } + [Required] + public required string PublicKeyPath { get; set; } - [Required] - public string Audience { get; set; } = "media-gateway"; + [Required] + public string Audience { get; set; } = "media-gateway"; - [Required] - public string Issuer { get; set; } = "musify-webapi"; + [Required] + public string Issuer { get; set; } = "musify-webapi"; - public string QueryParameterName { get; set; } = "t"; + public string QueryParameterName { get; set; } = "t"; - public string HeaderName { get; set; } = "X-Stream-Ticket"; + public string HeaderName { get; set; } = "X-Stream-Ticket"; - public string MediaPathPrefix { get; set; } = "/media"; + public string MediaPathPrefix { get; set; } = "/media"; + } } diff --git a/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs b/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs index 3aae1f1..b4cabc1 100644 --- a/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs +++ b/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs @@ -3,112 +3,113 @@ using Microsoft.Net.Http.Headers; using Musify.StreamingGateway.Authentication; using Musify.StreamingGateway.Configuration; -namespace Musify.StreamingGateway.Middleware; - -public sealed class TicketValidationMiddleware( - RequestDelegate next, - TicketValidator ticketValidator, - StreamTicketValidationConfiguration options, - ILogger logger) +namespace Musify.StreamingGateway.Middleware { - public async Task InvokeAsync(HttpContext context) + public sealed class TicketValidationMiddleware( + RequestDelegate next, + TicketValidator ticketValidator, + StreamTicketValidationConfiguration options, + ILogger logger) { - var path = context.Request.Path; - - if (!path.StartsWithSegments(options.MediaPathPrefix, out var remaining)) + public async Task InvokeAsync(HttpContext context) { + var path = context.Request.Path; + + if (!path.StartsWithSegments(options.MediaPathPrefix, out var remaining)) + { + await next(context); + return; + } + + var token = context.Request.Query[options.QueryParameterName].ToString(); + if (string.IsNullOrEmpty(token)) + { + token = context.Request.Headers[options.HeaderName].ToString(); + } + + var validation = await ticketValidator.ValidateTicketAsync(token); + + if (!validation.IsValid) + { + logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + var prefix = validation.Prefix; + var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty; + + if (HasTraversalSegment(objectKey)) + { + logger.LogWarning("Rejected media request {ObjectKey}: contains a path traversal segment", objectKey); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + var prefixBoundary = prefix.EndsWith('/') ? prefix : prefix + "/"; + if (!string.Equals(objectKey, prefix, StringComparison.Ordinal) + && !objectKey.StartsWith(prefixBoundary, StringComparison.Ordinal)) + { + logger.LogWarning("Rejected media request {ObjectKey}: outside authorized prefix {Prefix}", objectKey, prefix); + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + if (validation.MaxBytes is { } maxBytes && !TryClampRange(context, maxBytes)) + { + logger.LogInformation( + "Rejected media request {ObjectKey}: requested range is a suffix range or starts beyond the {MaxBytes}-byte preview limit", + objectKey, maxBytes); + context.Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable; + context.Response.Headers[HeaderNames.ContentRange] = $"bytes */{maxBytes}"; + return; + } + + StripQueryParameter(context, options.QueryParameterName); await next(context); - return; } - var token = context.Request.Query[options.QueryParameterName].ToString(); - if (string.IsNullOrEmpty(token)) + private static bool TryClampRange(HttpContext context, long maxBytes) { - token = context.Request.Headers[options.HeaderName].ToString(); - } + var rangeHeader = context.Request.Headers.Range; + if (StringValues.IsNullOrEmpty(rangeHeader)) + { + context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}"; + return true; + } - var validation = await ticketValidator.ValidateTicketAsync(token); + if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range) + || range.Ranges.Count != 1 + || !string.Equals(range.Unit.ToString(), "bytes", StringComparison.OrdinalIgnoreCase)) + { + context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}"; + return true; + } - if (!validation.IsValid) - { - logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - return; - } + var requested = range.Ranges.Single(); + if (requested.From is null) + return false; - var prefix = validation.Prefix; - var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty; + var start = requested.From.Value; + if (start >= maxBytes) + return false; - if (HasTraversalSegment(objectKey)) - { - logger.LogWarning("Rejected media request {ObjectKey}: contains a path traversal segment", objectKey); - context.Response.StatusCode = StatusCodes.Status400BadRequest; - return; - } - - var prefixBoundary = prefix.EndsWith('/') ? prefix : prefix + "/"; - if (!string.Equals(objectKey, prefix, StringComparison.Ordinal) - && !objectKey.StartsWith(prefixBoundary, StringComparison.Ordinal)) - { - logger.LogWarning("Rejected media request {ObjectKey}: outside authorized prefix {Prefix}", objectKey, prefix); - context.Response.StatusCode = StatusCodes.Status403Forbidden; - return; - } - - if (validation.MaxBytes is { } maxBytes && !TryClampRange(context, maxBytes)) - { - logger.LogInformation( - "Rejected media request {ObjectKey}: requested range is a suffix range or starts beyond the {MaxBytes}-byte preview limit", - objectKey, maxBytes); - context.Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable; - context.Response.Headers[HeaderNames.ContentRange] = $"bytes */{maxBytes}"; - return; - } - - StripQueryParameter(context, options.QueryParameterName); - await next(context); - } - - private static bool TryClampRange(HttpContext context, long maxBytes) - { - var rangeHeader = context.Request.Headers.Range; - if (StringValues.IsNullOrEmpty(rangeHeader)) - { - context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}"; + var end = requested.To is { } requestedEnd ? Math.Min(requestedEnd, maxBytes - 1) : maxBytes - 1; + context.Request.Headers.Range = $"bytes={start}-{end}"; return true; } - if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range) - || range.Ranges.Count != 1 - || !string.Equals(range.Unit.ToString(), "bytes", StringComparison.OrdinalIgnoreCase)) + private static bool HasTraversalSegment(string objectKey) => + objectKey.Split('/').Any(segment => segment is ".." or "."); + + private static void StripQueryParameter(HttpContext context, string name) { - context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}"; - return true; + var remaining = context.Request.Query + .Where(pair => !string.Equals(pair.Key, name, StringComparison.Ordinal)) + .SelectMany(pair => pair.Value.Select(value => new KeyValuePair(pair.Key, value))) + .ToList(); + + context.Request.QueryString = QueryString.Create(remaining); } - - var requested = range.Ranges.Single(); - if (requested.From is null) - return false; - - var start = requested.From.Value; - if (start >= maxBytes) - return false; - - var end = requested.To is { } requestedEnd ? Math.Min(requestedEnd, maxBytes - 1) : maxBytes - 1; - context.Request.Headers.Range = $"bytes={start}-{end}"; - return true; - } - - private static bool HasTraversalSegment(string objectKey) => - objectKey.Split('/').Any(segment => segment is ".." or "."); - - private static void StripQueryParameter(HttpContext context, string name) - { - var remaining = context.Request.Query - .Where(pair => !string.Equals(pair.Key, name, StringComparison.Ordinal)) - .SelectMany(pair => pair.Value.Select(value => new KeyValuePair(pair.Key, value))) - .ToList(); - - context.Request.QueryString = QueryString.Create(remaining); } } diff --git a/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs b/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs index 0336538..c6fc742 100644 --- a/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs +++ b/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs @@ -3,64 +3,65 @@ using Microsoft.AspNetCore.Http; using Musify.Api.Authentication; using Xunit; -namespace Musify.Api.Tests.Authentication; - -public sealed class CurrentUserTests +namespace Musify.Api.Tests.Authentication { - private static async Task BindAsync(ClaimsPrincipal principal) + public sealed class CurrentUserTests { - var context = new DefaultHttpContext { User = principal }; - var bound = await CurrentUser.BindAsync(context, parameter: null!); - return bound!; - } + private static async Task BindAsync(ClaimsPrincipal principal) + { + var context = new DefaultHttpContext { User = principal }; + var bound = await CurrentUser.BindAsync(context, parameter: null!); + return bound!; + } - private static ClaimsPrincipal AuthenticatedPrincipal(params Claim[] claims) => - new(new ClaimsIdentity(claims, authenticationType: "TestAuth")); + private static ClaimsPrincipal AuthenticatedPrincipal(params Claim[] claims) => + new(new ClaimsIdentity(claims, authenticationType: "TestAuth")); - [Fact] - public async Task BindAsync_AuthenticatedPrincipalWithValidClaims_PopulatesEveryField() - { - var principal = AuthenticatedPrincipal( - new Claim(ClaimTypes.NameIdentifier, "42"), - new Claim(ClaimTypes.Name, "jdoe"), - new Claim(ClaimTypes.GivenName, "Jane"), - new Claim(ClaimTypes.Surname, "Doe")); + [Fact] + public async Task BindAsync_AuthenticatedPrincipalWithValidClaims_PopulatesEveryField() + { + var principal = AuthenticatedPrincipal( + new Claim(ClaimTypes.NameIdentifier, "42"), + new Claim(ClaimTypes.Name, "jdoe"), + new Claim(ClaimTypes.GivenName, "Jane"), + new Claim(ClaimTypes.Surname, "Doe")); - var user = await BindAsync(principal); + var user = await BindAsync(principal); - Assert.True(user.IsAuthenticated); - Assert.Equal(42, user.Id); - Assert.Equal(42, user.RequiredId); - Assert.Equal("jdoe", user.Username); - Assert.Equal("Jane", user.FirstName); - Assert.Equal("Doe", user.LastName); - } + Assert.True(user.IsAuthenticated); + Assert.Equal(42, user.Id); + Assert.Equal(42, user.RequiredId); + Assert.Equal("jdoe", user.Username); + Assert.Equal("Jane", user.FirstName); + Assert.Equal("Doe", user.LastName); + } - [Fact] - public async Task BindAsync_AnonymousPrincipal_IsNotAuthenticatedAndHasNoId() - { - var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); + [Fact] + public async Task BindAsync_AnonymousPrincipal_IsNotAuthenticatedAndHasNoId() + { + var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); - Assert.False(user.IsAuthenticated); - Assert.Null(user.Id); - } + Assert.False(user.IsAuthenticated); + Assert.Null(user.Id); + } - [Theory] - [InlineData("not-a-number")] - [InlineData("0")] - [InlineData("")] - public async Task BindAsync_NonNumericOrZeroNameIdentifier_LeavesIdNull(string rawId) - { - var user = await BindAsync(AuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, rawId))); + [Theory] + [InlineData("not-a-number")] + [InlineData("0")] + [InlineData("")] + public async Task BindAsync_NonNumericOrZeroNameIdentifier_LeavesIdNull(string rawId) + { + var user = await BindAsync(AuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, rawId))); - Assert.Null(user.Id); - } + Assert.Null(user.Id); + } - [Fact] - public async Task RequiredId_MissingId_Throws() - { - var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); + [Fact] + public async Task RequiredId_MissingId_Throws() + { + var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); - Assert.Throws(() => user.RequiredId); + Assert.Throws(() => user.RequiredId); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index 0a79070..ae9389e 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -9,121 +9,122 @@ using Musify.Application.Pictures.Responses; using NSubstitute; using Xunit; -namespace Musify.Api.Tests.Endpoints; - -[Collection(ApiCollection.Name)] -public sealed class AlbumEndpointsTests(ApiTestFixture fixture) +namespace Musify.Api.Tests.Endpoints { - private Task CreateUserAsync() => fixture.SeedUserAsync(); - - private async Task CreatePictureIntentAsync(HttpClient client) + [Collection(ApiCollection.Name)] + public sealed class AlbumEndpointsTests(ApiTestFixture fixture) { - fixture.StorageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns("https://storage.musify.test/presigned-upload"); - fixture.StorageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); + private Task CreateUserAsync() => fixture.SeedUserAsync(); - var response = await client.PostAsJsonAsync( - "/albums/upload-picture", - new PictureUploadRequest("webp", "image/webp"), - TestContext.Current.CancellationToken); - response.EnsureSuccessStatusCode(); + private async Task CreatePictureIntentAsync(HttpClient client) + { + fixture.StorageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); + fixture.StorageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - return body!.IntentId; - } + var response = await client.PostAsJsonAsync( + "/albums/upload-picture", + new PictureUploadRequest("webp", "image/webp"), + TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); - [Fact] - public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized() - { - var response = await fixture.CreateAnonymousClient() - .PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + return body!.IntentId; + } - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } + [Fact] + public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized() + { + var response = await fixture.CreateAnonymousClient() + .PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); - var pictureIntentId = await CreatePictureIntentAsync(client); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } - var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken); + [Fact] + public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + var pictureIntentId = await CreatePictureIntentAsync(client); - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.NotNull(body); - Assert.Equal("My Album", body.Title); - Assert.Equal(userId, body.OwnerUserId); - } + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken); - [Fact] - public async Task PostAlbums_BlankTitle_ReturnsValidationProblem() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(body); + Assert.Equal("My Album", body.Title); + Assert.Equal(userId, body.OwnerUserId); + } - var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken); + [Fact] + public async Task PostAlbums_BlankTitle_ReturnsValidationProblem() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task PostAlbums_MissingPictureIntent_ReturnsValidationProblem() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } - var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("No Picture", null, null, Guid.Empty), TestContext.Current.CancellationToken); + [Fact] + public async Task PostAlbums_MissingPictureIntent_ReturnsValidationProblem() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("No Picture", null, null, Guid.Empty), TestContext.Current.CancellationToken); - [Fact] - public async Task GetAlbumById_AfterCreate_ReturnsIt() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); - var pictureIntentId = await CreatePictureIntentAsync(client); - var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null, pictureIntentId), TestContext.Current.CancellationToken)) - .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } - var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); + [Fact] + public async Task GetAlbumById_AfterCreate_ReturnsIt() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + var pictureIntentId = await CreatePictureIntentAsync(client); + var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null, pictureIntentId), TestContext.Current.CancellationToken)) + .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } + var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); - [Fact] - public async Task DeleteAlbum_NotTheOwner_ReturnsForbidden() - { - var owner = await CreateUserAsync(); - var stranger = await CreateUserAsync(); - var ownerClient = fixture.CreateAuthenticatedClient(owner); - var pictureIntentId = await CreatePictureIntentAsync(ownerClient); - var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken)) - .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } - var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); + [Fact] + public async Task DeleteAlbum_NotTheOwner_ReturnsForbidden() + { + var owner = await CreateUserAsync(); + var stranger = await CreateUserAsync(); + var ownerClient = fixture.CreateAuthenticatedClient(owner); + var pictureIntentId = await CreatePictureIntentAsync(ownerClient); + var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken)) + .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - } + var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); - [Fact] - public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); - var pictureIntentId = await CreatePictureIntentAsync(client); - var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null, pictureIntentId), TestContext.Current.CancellationToken)) - .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } - var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); - var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken); + [Fact] + public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + var pictureIntentId = await CreatePictureIntentAsync(client); + var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null, pictureIntentId), TestContext.Current.CancellationToken)) + .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); - Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); + var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs index 4f7f29c..41ec394 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs @@ -4,21 +4,22 @@ using Musify.Api.Tests.TestSupport; using Musify.Application.Configuration.Responses; using Xunit; -namespace Musify.Api.Tests.Endpoints; - -[Collection(ApiCollection.Name)] -public sealed class ConfigEndpointsTests(ApiTestFixture fixture) +namespace Musify.Api.Tests.Endpoints { - [Fact] - public async Task GetPlaybackConfig_WithoutAuthentication_ReturnsConfiguredValues() + [Collection(ApiCollection.Name)] + public sealed class ConfigEndpointsTests(ApiTestFixture fixture) { - var client = fixture.CreateAnonymousClient(); + [Fact] + public async Task GetPlaybackConfig_WithoutAuthentication_ReturnsConfiguredValues() + { + var client = fixture.CreateAnonymousClient(); - var response = await client.GetAsync("/config/playback", TestContext.Current.CancellationToken); + var response = await client.GetAsync("/config/playback", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.NotNull(body); - Assert.False(body.AllowAnonymousListening); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(body); + Assert.False(body.AllowAnonymousListening); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs index 28def3b..f5fed4f 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs @@ -12,94 +12,95 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.Persistence; using Xunit; -namespace Musify.Api.Tests.Endpoints; - -[Collection(ApiCollection.Name)] -public sealed class GenreEndpointsTests(ApiTestFixture fixture) +namespace Musify.Api.Tests.Endpoints { - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + [Collection(ApiCollection.Name)] + public sealed class GenreEndpointsTests(ApiTestFixture fixture) { - Converters = { new JsonStringEnumConverter() } - }; - - private async Task SeedTrackAsync(Genre genre) - { - using var scope = fixture.Services.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - var owner = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "genre-owner", NormalizedName = "GENRE-OWNER" }; - var title = $"Genre track {Guid.NewGuid():N}"; - var track = new Track + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - OwnerUserId = owner.Id, - Owner = owner, - Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }] + Converters = { new JsonStringEnumConverter() } }; - foreach (var tag in track.Tags) - tag.TrackId = track.Id; - database.Users.Add(owner); - database.Tracks.Add(track); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); - return title; - } - [Fact] - public async Task GetGenres_Anonymous_ListsGenresInUse() - { - await SeedTrackAsync(Genre.Funk); + private async Task SeedTrackAsync(Genre genre) + { + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var owner = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "genre-owner", NormalizedName = "GENRE-OWNER" }; + var title = $"Genre track {Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + OwnerUserId = owner.Id, + Owner = owner, + Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }] + }; + foreach (var tag in track.Tags) + tag.TrackId = track.Id; + database.Users.Add(owner); + database.Tracks.Add(track); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + return title; + } - var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken); + [Fact] + public async Task GetGenres_Anonymous_ListsGenresInUse() + { + await SeedTrackAsync(Genre.Funk); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); - Assert.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1); - } + var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken); - [Fact] - public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities() - { - var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1); + } - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); - Assert.Equal(Enum.GetValues().Length, body!.Count); - Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith); - } + [Fact] + public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", TestContext.Current.CancellationToken); - [Theory] - [InlineData("Nope")] - [InlineData("999")] - public async Task GetTracks_UnknownGenre_ReturnsBadRequest(string genre) - { - var response = await fixture.CreateAnonymousClient().GetAsync($"/tracks?genre={genre}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(Enum.GetValues().Length, body!.Count); + Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith); + } - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } + [Theory] + [InlineData("Nope")] + [InlineData("999")] + public async Task GetTracks_UnknownGenre_ReturnsBadRequest(string genre) + { + var response = await fixture.CreateAnonymousClient().GetAsync($"/tracks?genre={genre}", TestContext.Current.CancellationToken); - [Fact] - public async Task GetTracks_GenreQuery_IsCaseInsensitive() - { - var title = await SeedTrackAsync(Genre.Blues); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } - var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken); + [Fact] + public async Task GetTracks_GenreQuery_IsCaseInsensitive() + { + var title = await SeedTrackAsync(Genre.Blues); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); - Assert.Contains(body!.Items, item => item.Title == title); - } + var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken); - [Fact] - public async Task GetTracks_GenreQuery_FiltersByTag() - { - var title = await SeedTrackAsync(Genre.Ambient); - await SeedTrackAsync(Genre.Techno); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!.Items, item => item.Title == title); + } - var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken); + [Fact] + public async Task GetTracks_GenreQuery_FiltersByTag() + { + var title = await SeedTrackAsync(Genre.Ambient); + await SeedTrackAsync(Genre.Techno); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); - Assert.Contains(body!.Items, item => item.Title == title); - Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Tags)); + var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!.Items, item => item.Title == title); + Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Tags)); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs index 2067135..7d43fbf 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs @@ -8,93 +8,94 @@ using Musify.Domain.Entities; using Musify.Infrastructure.Persistence; using Xunit; -namespace Musify.Api.Tests.Endpoints; - -[Collection(ApiCollection.Name)] -public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture) +namespace Musify.Api.Tests.Endpoints { - private Task CreateUserAsync() => fixture.SeedUserAsync(); - - private async Task SeedListenAsync(long userId, double durationSeconds) + [Collection(ApiCollection.Name)] + public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture) { - using var scope = fixture.Services.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - var owner = await database.Users.SingleAsync(u => u.Id == userId, TestContext.Current.CancellationToken); - var track = new Track + private Task CreateUserAsync() => fixture.SeedUserAsync(); + + private async Task SeedListenAsync(long userId, double durationSeconds) { - Title = "Progress track", - NormalizedTitle = "PROGRESS TRACK", - OwnerUserId = owner.Id, - Owner = owner, - DurationSeconds = durationSeconds - }; - var listen = new ListeningHistory + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var owner = await database.Users.SingleAsync(u => u.Id == userId, TestContext.Current.CancellationToken); + var track = new Track + { + Title = "Progress track", + NormalizedTitle = "PROGRESS TRACK", + OwnerUserId = owner.Id, + Owner = owner, + DurationSeconds = durationSeconds + }; + var listen = new ListeningHistory + { + UserId = userId, + TrackId = track.Id, + ListenedAt = DateTime.UtcNow.AddMinutes(-10) + }; + database.Tracks.Add(track); + database.ListeningHistories.Add(listen); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + return listen.Id; + } + + private async Task LoadListenAsync(Guid listenId) { - UserId = userId, - TrackId = track.Id, - ListenedAt = DateTime.UtcNow.AddMinutes(-10) - }; - database.Tracks.Add(track); - database.ListeningHistories.Add(listen); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); - return listen.Id; - } + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + return await database.ListeningHistories.AsNoTracking() + .SingleAsync(l => l.Id == listenId, TestContext.Current.CancellationToken); + } - private async Task LoadListenAsync(Guid listenId) - { - using var scope = fixture.Services.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - return await database.ListeningHistories.AsNoTracking() - .SingleAsync(l => l.Id == listenId, TestContext.Current.CancellationToken); - } + [Fact] + public async Task PutProgress_Anonymous_ReturnsUnauthorized() + { + var response = await fixture.CreateAnonymousClient().PutAsJsonAsync( + $"/tracks/listens/{Guid.NewGuid()}/progress", new RecordListeningProgressRequest(10), TestContext.Current.CancellationToken); - [Fact] - public async Task PutProgress_Anonymous_ReturnsUnauthorized() - { - var response = await fixture.CreateAnonymousClient().PutAsJsonAsync( - $"/tracks/listens/{Guid.NewGuid()}/progress", new RecordListeningProgressRequest(10), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } + [Fact] + public async Task PutProgress_OwnListen_StoresSecondsAndMarksCounted() + { + var userId = await CreateUserAsync(); + var listenId = await SeedListenAsync(userId, durationSeconds: 200); - [Fact] - public async Task PutProgress_OwnListen_StoresSecondsAndMarksCounted() - { - var userId = await CreateUserAsync(); - var listenId = await SeedListenAsync(userId, durationSeconds: 200); + var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync( + $"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken); - var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync( - $"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + var stored = await LoadListenAsync(listenId); + Assert.Equal(45, stored.PlayedSeconds); + Assert.True(stored.IsCounted); + } - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - var stored = await LoadListenAsync(listenId); - Assert.Equal(45, stored.PlayedSeconds); - Assert.True(stored.IsCounted); - } + [Fact] + public async Task PutProgress_OtherUsersListen_ReturnsNotFound() + { + var ownerId = await CreateUserAsync(); + var otherId = await CreateUserAsync(); + var listenId = await SeedListenAsync(ownerId, durationSeconds: 200); - [Fact] - public async Task PutProgress_OtherUsersListen_ReturnsNotFound() - { - var ownerId = await CreateUserAsync(); - var otherId = await CreateUserAsync(); - var listenId = await SeedListenAsync(ownerId, durationSeconds: 200); + var response = await fixture.CreateAuthenticatedClient(otherId).PutAsJsonAsync( + $"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken); - var response = await fixture.CreateAuthenticatedClient(otherId).PutAsJsonAsync( - $"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Null((await LoadListenAsync(listenId)).PlayedSeconds); + } - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Null((await LoadListenAsync(listenId)).PlayedSeconds); - } + [Fact] + public async Task PutProgress_NegativeSeconds_ReturnsBadRequest() + { + var userId = await CreateUserAsync(); + var listenId = await SeedListenAsync(userId, durationSeconds: 200); - [Fact] - public async Task PutProgress_NegativeSeconds_ReturnsBadRequest() - { - var userId = await CreateUserAsync(); - var listenId = await SeedListenAsync(userId, durationSeconds: 200); + var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync( + $"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(-5), TestContext.Current.CancellationToken); - var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync( - $"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(-5), TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs index faa9eda..d876e01 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs @@ -9,148 +9,149 @@ using Musify.Application.Shared; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Api.Tests.Endpoints; - -[Collection(ApiCollection.Name)] -public sealed class PlayListEndpointsTests(ApiTestFixture fixture) +namespace Musify.Api.Tests.Endpoints { - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + [Collection(ApiCollection.Name)] + public sealed class PlayListEndpointsTests(ApiTestFixture fixture) { - Converters = { new JsonStringEnumConverter() } - }; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; - private static CancellationToken Ct => TestContext.Current.CancellationToken; + private static CancellationToken Ct => TestContext.Current.CancellationToken; - private async Task CreatePlayListAsync(long ownerId, PlayListVisibility visibility, string? name = null) - { - var response = await fixture.CreateAuthenticatedClient(ownerId).PostAsJsonAsync( - "/playlists", - new CreatePlayListRequest(name ?? $"pl-{Guid.NewGuid():N}"[..20], null, null, visibility), - Ct); - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - return (await response.Content.ReadFromJsonAsync(JsonOptions, Ct))!; - } + private async Task CreatePlayListAsync(long ownerId, PlayListVisibility visibility, string? name = null) + { + var response = await fixture.CreateAuthenticatedClient(ownerId).PostAsJsonAsync( + "/playlists", + new CreatePlayListRequest(name ?? $"pl-{Guid.NewGuid():N}"[..20], null, null, visibility), + Ct); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(JsonOptions, Ct))!; + } - [Fact] - public async Task GetPlayListById_PrivatePlayList_IsOnlyVisibleToItsOwner() - { - var owner = await fixture.SeedUserAsync(); - var stranger = await fixture.SeedUserAsync(); - var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + [Fact] + public async Task GetPlayListById_PrivatePlayList_IsOnlyVisibleToItsOwner() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); - var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}", Ct); - var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}", Ct); - var asAnonymous = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct); + var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}", Ct); + var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}", Ct); + var asAnonymous = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct); - Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode); - Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode); - Assert.Equal(HttpStatusCode.NotFound, asAnonymous.StatusCode); - } + Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, asAnonymous.StatusCode); + } - [Fact] - public async Task GetPlayListById_PublicPlayList_IsVisibleToAnonymousUsers() - { - var owner = await fixture.SeedUserAsync(); - var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + [Fact] + public async Task GetPlayListById_PublicPlayList_IsVisibleToAnonymousUsers() + { + var owner = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); - var response = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct); + var response = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } - [Fact] - public async Task GetPlayListTracks_PrivatePlayList_IsHiddenFromStrangers() - { - var owner = await fixture.SeedUserAsync(); - var stranger = await fixture.SeedUserAsync(); - var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + [Fact] + public async Task GetPlayListTracks_PrivatePlayList_IsHiddenFromStrangers() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); - var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}/tracks", Ct); - var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}/tracks", Ct); + var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}/tracks", Ct); + var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}/tracks", Ct); - Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode); - Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode); - } + Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode); + } - [Fact] - public async Task GetPlayListsByUserId_StrangersOnlySeePublicPlayLists() - { - var owner = await fixture.SeedUserAsync(); - var stranger = await fixture.SeedUserAsync(); - var publicPlayList = await CreatePlayListAsync(owner, PlayListVisibility.Public); - var privatePlayList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + [Fact] + public async Task GetPlayListsByUserId_StrangersOnlySeePublicPlayLists() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var publicPlayList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + var privatePlayList = await CreatePlayListAsync(owner, PlayListVisibility.Private); - var asStranger = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(stranger), owner); - var asOwner = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(owner), owner); + var asStranger = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(stranger), owner); + var asOwner = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(owner), owner); - Assert.Equal([publicPlayList.Id], asStranger); - Assert.Contains(publicPlayList.Id, asOwner); - Assert.Contains(privatePlayList.Id, asOwner); - } + Assert.Equal([publicPlayList.Id], asStranger); + Assert.Contains(publicPlayList.Id, asOwner); + Assert.Contains(privatePlayList.Id, asOwner); + } - [Fact] - public async Task UpdatePlayList_NotTheOwner_ReturnsForbidden() - { - var owner = await fixture.SeedUserAsync(); - var stranger = await fixture.SeedUserAsync(); - var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + [Fact] + public async Task UpdatePlayList_NotTheOwner_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); - var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync( - $"/playlists/{playList.Id}", new UpdatePlayListRequest("Hijacked", null, null), Ct); + var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync( + $"/playlists/{playList.Id}", new UpdatePlayListRequest("Hijacked", null, null), Ct); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - } + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } - [Fact] - public async Task DeletePlayList_NotTheOwner_ReturnsForbidden() - { - var owner = await fixture.SeedUserAsync(); - var stranger = await fixture.SeedUserAsync(); - var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + [Fact] + public async Task DeletePlayList_NotTheOwner_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); - var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/playlists/{playList.Id}", Ct); + var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/playlists/{playList.Id}", Ct); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - } + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } - [Fact] - public async Task UpdatePlayList_Owner_RenamesIt() - { - var owner = await fixture.SeedUserAsync(); - var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + [Fact] + public async Task UpdatePlayList_Owner_RenamesIt() + { + var owner = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); - var response = await fixture.CreateAuthenticatedClient(owner).PutAsJsonAsync( - $"/playlists/{playList.Id}", new UpdatePlayListRequest("Renamed", null, null), Ct); + var response = await fixture.CreateAuthenticatedClient(owner).PutAsJsonAsync( + $"/playlists/{playList.Id}", new UpdatePlayListRequest("Renamed", null, null), Ct); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(JsonOptions, Ct); - Assert.Equal("Renamed", body!.Name); - } + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, Ct); + Assert.Equal("Renamed", body!.Name); + } - [Fact] - public async Task GetPlayListById_NotAGuid_ReturnsNotFound() - { - var response = await fixture.CreateAnonymousClient().GetAsync("/playlists/not-a-guid", Ct); + [Fact] + public async Task GetPlayListById_NotAGuid_ReturnsNotFound() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/playlists/not-a-guid", Ct); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } - [Fact] - public async Task CreatePlayList_NameTooLong_ReturnsValidationProblem() - { - var owner = await fixture.SeedUserAsync(); + [Fact] + public async Task CreatePlayList_NameTooLong_ReturnsValidationProblem() + { + var owner = await fixture.SeedUserAsync(); - var response = await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync( - "/playlists", new CreatePlayListRequest(new string('a', 51), null, null), Ct); + var response = await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync( + "/playlists", new CreatePlayListRequest(new string('a', 51), null, null), Ct); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } - private static async Task> ReadPlayListIdsAsync(HttpClient client, long userId) - { - var response = await client.GetAsync($"/playlists/users/{userId}", Ct); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var page = await response.Content.ReadFromJsonAsync>(JsonOptions, Ct); - return page!.Items.Select(playList => playList.Id).ToList(); + private static async Task> ReadPlayListIdsAsync(HttpClient client, long userId) + { + var response = await client.GetAsync($"/playlists/users/{userId}", Ct); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var page = await response.Content.ReadFromJsonAsync>(JsonOptions, Ct); + return page!.Items.Select(playList => playList.Id).ToList(); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs index 62dd90e..43498e2 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs @@ -11,131 +11,132 @@ using Musify.Domain.Entities; using Musify.Infrastructure.Persistence; using Xunit; -namespace Musify.Api.Tests.Endpoints; - -[Collection(ApiCollection.Name)] -public sealed class UserEndpointsTests(ApiTestFixture fixture) +namespace Musify.Api.Tests.Endpoints { - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + [Collection(ApiCollection.Name)] + public sealed class UserEndpointsTests(ApiTestFixture fixture) { - Converters = { new JsonStringEnumConverter() } - }; - - private async Task SeedListenAsync(long userId, string trackTitle, DateTime listenedAt) - { - using var scope = fixture.Services.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - var owner = await database.Users.SingleAsync(u => u.Id == userId); - var track = new Track + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { - Title = trackTitle, - NormalizedTitle = trackTitle.ToUpperInvariant(), - OwnerUserId = owner.Id, - Owner = owner + Converters = { new JsonStringEnumConverter() } }; - database.Tracks.Add(track); - database.ListeningHistories.Add(new ListeningHistory + + private async Task SeedListenAsync(long userId, string trackTitle, DateTime listenedAt) { - UserId = userId, - TrackId = track.Id, - ListenedAt = listenedAt, - IsCounted = true - }); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); - } + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var owner = await database.Users.SingleAsync(u => u.Id == userId); + var track = new Track + { + Title = trackTitle, + NormalizedTitle = trackTitle.ToUpperInvariant(), + OwnerUserId = owner.Id, + Owner = owner + }; + database.Tracks.Add(track); + database.ListeningHistories.Add(new ListeningHistory + { + UserId = userId, + TrackId = track.Id, + ListenedAt = listenedAt, + IsCounted = true + }); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + } - [Fact] - public async Task PostUsers_IsNotExposed() - { - var client = fixture.CreateAnonymousClient(); + [Fact] + public async Task PostUsers_IsNotExposed() + { + var client = fixture.CreateAnonymousClient(); - var response = await client.PostAsJsonAsync("/users", new { id = 1, name = "Intruder" }, TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/users", new { id = 1, name = "Intruder" }, TestContext.Current.CancellationToken); - Assert.True(response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed); - } + Assert.True(response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed); + } - [Fact] - public async Task GetUserById_ExistingUser_ReturnsIt() - { - var client = fixture.CreateAnonymousClient(); - var userId = Random.Shared.NextInt64(1, long.MaxValue); - await fixture.SeedUserAsync(userId, "Findable User"); + [Fact] + public async Task GetUserById_ExistingUser_ReturnsIt() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await fixture.SeedUserAsync(userId, "Findable User"); - var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken); + var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.Equal("Findable User", body?.Name); - } + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal("Findable User", body?.Name); + } - [Fact] - public async Task GetUserById_MissingUser_ReturnsNotFound() - { - var client = fixture.CreateAnonymousClient(); + [Fact] + public async Task GetUserById_MissingUser_ReturnsNotFound() + { + var client = fixture.CreateAnonymousClient(); - var response = await client.GetAsync($"/users/{Random.Shared.NextInt64(1, long.MaxValue)}", TestContext.Current.CancellationToken); + var response = await client.GetAsync($"/users/{Random.Shared.NextInt64(1, long.MaxValue)}", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } - [Fact] - public async Task GetLastTrackListenedByUserId_HasHistory_ReturnsMostRecentTrack() - { - var client = fixture.CreateAnonymousClient(); - var userId = Random.Shared.NextInt64(1, long.MaxValue); - await fixture.SeedUserAsync(userId); - await SeedListenAsync(userId, "Older listen", DateTime.UtcNow.AddMinutes(-10)); - await SeedListenAsync(userId, "Newer listen", DateTime.UtcNow); + [Fact] + public async Task GetLastTrackListenedByUserId_HasHistory_ReturnsMostRecentTrack() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await fixture.SeedUserAsync(userId); + await SeedListenAsync(userId, "Older listen", DateTime.UtcNow.AddMinutes(-10)); + await SeedListenAsync(userId, "Newer listen", DateTime.UtcNow); - var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); + var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); - Assert.Equal("Newer listen", body?.Title); - } + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); + Assert.Equal("Newer listen", body?.Title); + } - [Fact] - public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNoContent() - { - var client = fixture.CreateAnonymousClient(); - var userId = Random.Shared.NextInt64(1, long.MaxValue); - await fixture.SeedUserAsync(userId); + [Fact] + public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNoContent() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await fixture.SeedUserAsync(userId); - var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); + var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - } + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } - [Theory] - [InlineData("pageSize=0")] - [InlineData("pageSize=101")] - [InlineData("pageNumber=0")] - public async Task GetUsers_InvalidPaging_ReturnsBadRequest(string query) - { - var client = fixture.CreateAnonymousClient(); + [Theory] + [InlineData("pageSize=0")] + [InlineData("pageSize=101")] + [InlineData("pageNumber=0")] + public async Task GetUsers_InvalidPaging_ReturnsBadRequest(string query) + { + var client = fixture.CreateAnonymousClient(); - var response = await client.GetAsync($"/users?{query}", TestContext.Current.CancellationToken); + var response = await client.GetAsync($"/users?{query}", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } - [Fact] - public async Task GetListeningStats_AnotherUser_ReturnsForbidden() - { - var userId = await fixture.SeedUserAsync(); - var other = await fixture.SeedUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); + [Fact] + public async Task GetListeningStats_AnotherUser_ReturnsForbidden() + { + var userId = await fixture.SeedUserAsync(); + var other = await fixture.SeedUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); - var response = await client.GetAsync($"/users/{other}/listening-stats", TestContext.Current.CancellationToken); + var response = await client.GetAsync($"/users/{other}/listening-stats", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - } + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } - [Fact] - public async Task GetListeningStats_Anonymous_ReturnsUnauthorized() - { - var response = await fixture.CreateAnonymousClient().GetAsync("/users/1/listening-stats", TestContext.Current.CancellationToken); + [Fact] + public async Task GetListeningStats_Anonymous_ReturnsUnauthorized() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/users/1/listening-stats", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs b/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs index 8b6320a..773c1ca 100644 --- a/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs @@ -4,132 +4,133 @@ using Microsoft.Extensions.DependencyInjection; using Musify.Api.Extensions; using Xunit; -namespace Musify.Api.Tests.Extensions; - -public sealed class ErrorOrHttpExtensionsTests +namespace Musify.Api.Tests.Extensions { - private static readonly IServiceProvider Services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider(); - - private static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result) + public sealed class ErrorOrHttpExtensionsTests { - var context = new DefaultHttpContext { RequestServices = Services }; - var bodyStream = new MemoryStream(); - context.Response.Body = bodyStream; + private static readonly IServiceProvider Services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider(); - await result.ExecuteAsync(context); - - bodyStream.Position = 0; - using var reader = new StreamReader(bodyStream); - return (context.Response.StatusCode, await reader.ReadToEndAsync()); - } - - [Fact] - public async Task ToHttpResult_Success_ReturnsNoContent() - { - ErrorOr result = new Success(); - - var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); - - Assert.Equal(StatusCodes.Status204NoContent, statusCode); - } - - [Fact] - public async Task ToHttpResult_Value_ReturnsOkWithTheValue() - { - ErrorOr result = "hello"; - - var (statusCode, body) = await ExecuteAsync(result.ToHttpResult()); - - Assert.Equal(StatusCodes.Status200OK, statusCode); - Assert.Contains("hello", body); - } - - [Fact] - public async Task ToHttpResult_NotFoundError_ReturnsNotFound() - { - ErrorOr result = Error.NotFound(description: "not here"); - - var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); - - Assert.Equal(StatusCodes.Status404NotFound, statusCode); - } - - [Fact] - public async Task ToHttpResult_UnauthorizedError_ReturnsUnauthorized() - { - ErrorOr result = Error.Unauthorized(); - - var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); - - Assert.Equal(StatusCodes.Status401Unauthorized, statusCode); - } - - [Fact] - public async Task ToHttpResult_ForbiddenError_ReturnsForbidden() - { - ErrorOr result = Error.Forbidden(); - - var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); - - Assert.Equal(StatusCodes.Status403Forbidden, statusCode); - } - - [Fact] - public async Task ToHttpResult_ConflictError_ReturnsConflict() - { - ErrorOr result = Error.Conflict(); - - var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); - - Assert.Equal(StatusCodes.Status409Conflict, statusCode); - } - - [Fact] - public async Task ToHttpResult_FailureError_ReturnsProblem500() - { - ErrorOr result = Error.Failure(); - - var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); - - Assert.Equal(StatusCodes.Status500InternalServerError, statusCode); - } - - [Fact] - public async Task ToHttpResult_ValidationErrors_ReturnsValidationProblem() - { - ErrorOr result = new List + private static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result) { - Error.Validation("Name", "Name is required"), - Error.Validation("Name", "Name is too long"), - Error.Validation("Age", "Age must be positive"), - }; + var context = new DefaultHttpContext { RequestServices = Services }; + var bodyStream = new MemoryStream(); + context.Response.Body = bodyStream; - var (statusCode, body) = await ExecuteAsync(result.ToHttpResult()); + await result.ExecuteAsync(context); - Assert.Equal(StatusCodes.Status400BadRequest, statusCode); - Assert.Contains("Name is required", body); - Assert.Contains("Age must be positive", body); - } + bodyStream.Position = 0; + using var reader = new StreamReader(bodyStream); + return (context.Response.StatusCode, await reader.ReadToEndAsync()); + } - [Fact] - public async Task ToCreatedResult_Success_ReturnsCreatedAtTheGivenLocation() - { - ErrorOr result = "the-id"; + [Fact] + public async Task ToHttpResult_Success_ReturnsNoContent() + { + ErrorOr result = new Success(); - var context = new DefaultHttpContext { RequestServices = Services, Response = { Body = new MemoryStream() } }; - await result.ToCreatedResult(value => $"/things/{value}").ExecuteAsync(context); + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); - Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode); - Assert.Equal("/things/the-id", context.Response.Headers.Location); - } + Assert.Equal(StatusCodes.Status204NoContent, statusCode); + } - [Fact] - public async Task ToCreatedResult_Error_ReturnsTheMappedErrorStatus() - { - ErrorOr result = Error.Conflict(); + [Fact] + public async Task ToHttpResult_Value_ReturnsOkWithTheValue() + { + ErrorOr result = "hello"; - var (statusCode, _) = await ExecuteAsync(result.ToCreatedResult(value => $"/things/{value}")); + var (statusCode, body) = await ExecuteAsync(result.ToHttpResult()); - Assert.Equal(StatusCodes.Status409Conflict, statusCode); + Assert.Equal(StatusCodes.Status200OK, statusCode); + Assert.Contains("hello", body); + } + + [Fact] + public async Task ToHttpResult_NotFoundError_ReturnsNotFound() + { + ErrorOr result = Error.NotFound(description: "not here"); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status404NotFound, statusCode); + } + + [Fact] + public async Task ToHttpResult_UnauthorizedError_ReturnsUnauthorized() + { + ErrorOr result = Error.Unauthorized(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status401Unauthorized, statusCode); + } + + [Fact] + public async Task ToHttpResult_ForbiddenError_ReturnsForbidden() + { + ErrorOr result = Error.Forbidden(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status403Forbidden, statusCode); + } + + [Fact] + public async Task ToHttpResult_ConflictError_ReturnsConflict() + { + ErrorOr result = Error.Conflict(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status409Conflict, statusCode); + } + + [Fact] + public async Task ToHttpResult_FailureError_ReturnsProblem500() + { + ErrorOr result = Error.Failure(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status500InternalServerError, statusCode); + } + + [Fact] + public async Task ToHttpResult_ValidationErrors_ReturnsValidationProblem() + { + ErrorOr result = new List + { + Error.Validation("Name", "Name is required"), + Error.Validation("Name", "Name is too long"), + Error.Validation("Age", "Age must be positive"), + }; + + var (statusCode, body) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status400BadRequest, statusCode); + Assert.Contains("Name is required", body); + Assert.Contains("Age must be positive", body); + } + + [Fact] + public async Task ToCreatedResult_Success_ReturnsCreatedAtTheGivenLocation() + { + ErrorOr result = "the-id"; + + var context = new DefaultHttpContext { RequestServices = Services, Response = { Body = new MemoryStream() } }; + await result.ToCreatedResult(value => $"/things/{value}").ExecuteAsync(context); + + Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode); + Assert.Equal("/things/the-id", context.Response.Headers.Location); + } + + [Fact] + public async Task ToCreatedResult_Error_ReturnsTheMappedErrorStatus() + { + ErrorOr result = Error.Conflict(); + + var (statusCode, _) = await ExecuteAsync(result.ToCreatedResult(value => $"/things/{value}")); + + Assert.Equal(StatusCodes.Status409Conflict, statusCode); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs b/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs index 18ecdcc..8b16cfe 100644 --- a/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs +++ b/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs @@ -6,56 +6,57 @@ using Musify.Api.Filters; using NSubstitute; using Xunit; -namespace Musify.Api.Tests.Filters; - -public sealed class ValidationFilterTests +namespace Musify.Api.Tests.Filters { - public sealed record Payload(string Name); - - private static EndpointFilterInvocationContext CreateContext(T argument) => - EndpointFilterInvocationContext.Create(new DefaultHttpContext(), argument); - - [Fact] - public async Task InvokeAsync_ValidPayload_CallsNext() + public sealed class ValidationFilterTests { - var validator = Substitute.For>(); - validator.ValidateAsync(Arg.Any(), Arg.Any()).Returns(new ValidationResult()); - var filter = new ValidationFilter(validator); - var context = CreateContext(new Payload("ok")); + public sealed record Payload(string Name); - var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("next-called")); + private static EndpointFilterInvocationContext CreateContext(T argument) => + EndpointFilterInvocationContext.Create(new DefaultHttpContext(), argument); - Assert.Equal("next-called", result); - await validator.Received(1).ValidateAsync(Arg.Is(p => p.Name == "ok"), Arg.Any()); - } + [Fact] + public async Task InvokeAsync_ValidPayload_CallsNext() + { + var validator = Substitute.For>(); + validator.ValidateAsync(Arg.Any(), Arg.Any()).Returns(new ValidationResult()); + var filter = new ValidationFilter(validator); + var context = CreateContext(new Payload("ok")); - [Fact] - public async Task InvokeAsync_InvalidPayload_ReturnsValidationProblemWithoutCallingNext() - { - var validator = Substitute.For>(); - validator.ValidateAsync(Arg.Any(), Arg.Any()) - .Returns(new ValidationResult([new ValidationFailure("Name", "Name is required")])); - var filter = new ValidationFilter(validator); - var context = CreateContext(new Payload("")); - var nextCalled = false; + var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("next-called")); - var result = await filter.InvokeAsync(context, _ => { nextCalled = true; return ValueTask.FromResult(null); }); + Assert.Equal("next-called", result); + await validator.Received(1).ValidateAsync(Arg.Is(p => p.Name == "ok"), Arg.Any()); + } - Assert.False(nextCalled); - var problem = Assert.IsType(result); - Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode); - } + [Fact] + public async Task InvokeAsync_InvalidPayload_ReturnsValidationProblemWithoutCallingNext() + { + var validator = Substitute.For>(); + validator.ValidateAsync(Arg.Any(), Arg.Any()) + .Returns(new ValidationResult([new ValidationFailure("Name", "Name is required")])); + var filter = new ValidationFilter(validator); + var context = CreateContext(new Payload("")); + var nextCalled = false; - [Fact] - public async Task InvokeAsync_NoMatchingArgument_CallsNextWithoutValidating() - { - var validator = Substitute.For>(); - var filter = new ValidationFilter(validator); - var context = CreateContext(42); + var result = await filter.InvokeAsync(context, _ => { nextCalled = true; return ValueTask.FromResult(null); }); - var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("next-called")); + Assert.False(nextCalled); + var problem = Assert.IsType(result); + Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode); + } - Assert.Equal("next-called", result); - await validator.DidNotReceiveWithAnyArgs().ValidateAsync(Arg.Any(), Arg.Any()); + [Fact] + public async Task InvokeAsync_NoMatchingArgument_CallsNextWithoutValidating() + { + var validator = Substitute.For>(); + var filter = new ValidationFilter(validator); + var context = CreateContext(42); + + var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("next-called")); + + Assert.Equal("next-called", result); + await validator.DidNotReceiveWithAnyArgs().ValidateAsync(Arg.Any(), Arg.Any()); + } } } diff --git a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs index 83751b4..5a3887b 100644 --- a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs +++ b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs @@ -12,108 +12,109 @@ using NSubstitute; using Testcontainers.PostgreSql; using Xunit; -namespace Musify.Api.Tests.TestSupport; - -public sealed class ApiTestFixture : WebApplicationFactory, IAsyncLifetime +namespace Musify.Api.Tests.TestSupport { - private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") - .WithDatabase("musify_api_test") - .WithUsername("postgres") - .WithPassword("postgres") - .Build(); - - public IStorageService StorageService { get; } = Substitute.For(); - - public IEventBus EventBus { get; } = Substitute.For(); - - public IStreamTicketService StreamTicketService { get; } = Substitute.For(); - - public async ValueTask InitializeAsync() + public sealed class ApiTestFixture : WebApplicationFactory, IAsyncLifetime { - await postgres.StartAsync(); + private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase("musify_api_test") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); - using var scope = Services.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - await database.Database.MigrateAsync(); - } + public IStorageService StorageService { get; } = Substitute.For(); - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync(); - await postgres.DisposeAsync(); - } + public IEventBus EventBus { get; } = Substitute.For(); - public HttpClient CreateAnonymousClient() => CreateClient(); + public IStreamTicketService StreamTicketService { get; } = Substitute.For(); - public async Task SeedUserAsync(long? userId = null, string? name = null) - { - var id = userId ?? Random.Shared.NextInt64(1, long.MaxValue); - var userName = name ?? $"user-{id}"; - - using var scope = Services.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - database.Users.Add(new Musify.Domain.Entities.User + public async ValueTask InitializeAsync() { - Id = id, - Name = userName, - NormalizedName = userName.ToUpperInvariant() - }); - await database.SaveChangesAsync(); - return id; - } + await postgres.StartAsync(); - public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user") - { - var client = CreateClient(); - client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString(CultureInfo.InvariantCulture)); - client.DefaultRequestHeaders.Add("X-Test-User-Name", userName); - return client; - } + using var scope = Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + await database.Database.MigrateAsync(); + } - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.ConfigureAppConfiguration((_, configuration) => + public override async ValueTask DisposeAsync() { - configuration.AddInMemoryCollection(new Dictionary + await base.DisposeAsync(); + await postgres.DisposeAsync(); + } + + public HttpClient CreateAnonymousClient() => CreateClient(); + + public async Task SeedUserAsync(long? userId = null, string? name = null) + { + var id = userId ?? Random.Shared.NextInt64(1, long.MaxValue); + var userName = name ?? $"user-{id}"; + + using var scope = Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + database.Users.Add(new Musify.Domain.Entities.User { - ["Database:ConnectionString"] = postgres.GetConnectionString(), - ["Authentication:MetadataAddress"] = "https://auth.musify.test/.well-known/openid-configuration", - ["Authentication:IssuerAddress"] = "https://auth.musify.test", - ["Authentication:AudienceAddress"] = "musify-api-test", - ["Authentication:ClientId"] = "musify-api-test", - ["Authentication:AuthorizationEndpoint"] = "https://auth.musify.test/authorize", - ["Authentication:TokenEndpoint"] = "https://auth.musify.test/token", - ["Authentication:Scopes:0"] = "openid", - ["StreamTicket:PrivateKeyPath"] = "unused-in-tests.pem", - ["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317", - ["MassTransit:Address"] = "rabbitmq://localhost:5672", - ["MassTransit:Username"] = "guest", - ["MassTransit:Password"] = "guest", - ["InfrastructureStorage:Address"] = "http://localhost:8333", - ["InfrastructureStorage:AccessKey"] = "unused-in-tests", - ["InfrastructureStorage:SecretAccessKey"] = "unused-in-tests", - ["InfrastructureStorage:ForcePathStyle"] = "true", - ["InfrastructureStorage:UseHttp"] = "true", - ["ApplicationStorage:Bucket"] = "musify-api-test", - ["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test", + Id = id, + Name = userName, + NormalizedName = userName.ToUpperInvariant() }); - }); + await database.SaveChangesAsync(); + return id; + } - builder.ConfigureTestServices(services => + public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user") { - services.Replace(ServiceDescriptor.Singleton(StorageService)); - services.Replace(ServiceDescriptor.Scoped(_ => EventBus)); - services.Replace(ServiceDescriptor.Singleton(StreamTicketService)); + var client = CreateClient(); + client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString(CultureInfo.InvariantCulture)); + client.DefaultRequestHeaders.Add("X-Test-User-Name", userName); + return client; + } - services - .AddAuthentication(FakeAuthenticationHandler.SchemeName) - .AddScheme(FakeAuthenticationHandler.SchemeName, _ => { }); - }); + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["Database:ConnectionString"] = postgres.GetConnectionString(), + ["Authentication:MetadataAddress"] = "https://auth.musify.test/.well-known/openid-configuration", + ["Authentication:IssuerAddress"] = "https://auth.musify.test", + ["Authentication:AudienceAddress"] = "musify-api-test", + ["Authentication:ClientId"] = "musify-api-test", + ["Authentication:AuthorizationEndpoint"] = "https://auth.musify.test/authorize", + ["Authentication:TokenEndpoint"] = "https://auth.musify.test/token", + ["Authentication:Scopes:0"] = "openid", + ["StreamTicket:PrivateKeyPath"] = "unused-in-tests.pem", + ["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317", + ["MassTransit:Address"] = "rabbitmq://localhost:5672", + ["MassTransit:Username"] = "guest", + ["MassTransit:Password"] = "guest", + ["InfrastructureStorage:Address"] = "http://localhost:8333", + ["InfrastructureStorage:AccessKey"] = "unused-in-tests", + ["InfrastructureStorage:SecretAccessKey"] = "unused-in-tests", + ["InfrastructureStorage:ForcePathStyle"] = "true", + ["InfrastructureStorage:UseHttp"] = "true", + ["ApplicationStorage:Bucket"] = "musify-api-test", + ["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test", + }); + }); + + builder.ConfigureTestServices(services => + { + services.Replace(ServiceDescriptor.Singleton(StorageService)); + services.Replace(ServiceDescriptor.Scoped(_ => EventBus)); + services.Replace(ServiceDescriptor.Singleton(StreamTicketService)); + + services + .AddAuthentication(FakeAuthenticationHandler.SchemeName) + .AddScheme(FakeAuthenticationHandler.SchemeName, _ => { }); + }); + } + } + + [CollectionDefinition(Name)] + public sealed class ApiCollection : ICollectionFixture + { + public const string Name = "Api"; } } - -[CollectionDefinition(Name)] -public sealed class ApiCollection : ICollectionFixture -{ - public const string Name = "Api"; -} diff --git a/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs b/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs index c61ea8e..497bc7b 100644 --- a/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs +++ b/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs @@ -4,28 +4,29 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Musify.Api.Tests.TestSupport; - -public sealed class FakeAuthenticationHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder) - : AuthenticationHandler(options, logger, encoder) +namespace Musify.Api.Tests.TestSupport { - public const string SchemeName = "Test"; - public const string UserIdHeader = "X-Test-User-Id"; - - protected override Task HandleAuthenticateAsync() + public sealed class FakeAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) { - if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId)) - return Task.FromResult(AuthenticateResult.NoResult()); + public const string SchemeName = "Test"; + public const string UserIdHeader = "X-Test-User-Id"; - var claims = new List { new(ClaimTypes.NameIdentifier, userId!) }; - if (Request.Headers.TryGetValue("X-Test-User-Name", out var userName) && !string.IsNullOrEmpty(userName)) - claims.Add(new Claim(ClaimTypes.Name, userName!)); + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId)) + return Task.FromResult(AuthenticateResult.NoResult()); - var identity = new ClaimsIdentity(claims, SchemeName); - var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName); - return Task.FromResult(AuthenticateResult.Success(ticket)); + var claims = new List { new(ClaimTypes.NameIdentifier, userId!) }; + if (Request.Headers.TryGetValue("X-Test-User-Name", out var userName) && !string.IsNullOrEmpty(userName)) + claims.Add(new Claim(ClaimTypes.Name, userName!)); + + var identity = new ClaimsIdentity(claims, SchemeName); + var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs index 728c829..e591762 100644 --- a/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs +++ b/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs @@ -3,83 +3,84 @@ using Musify.Api.Validators; using Musify.Api.Validators.Albums; using Xunit; -namespace Musify.Api.Tests.Validators; - -public sealed class CreateAlbumRequestValidatorTests +namespace Musify.Api.Tests.Validators { - private readonly CreateAlbumRequestValidator validator = new(); - - [Fact] - public void Validate_ValidRequest_Passes() + public sealed class CreateAlbumRequestValidatorTests { - var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024, Guid.NewGuid())); + private readonly CreateAlbumRequestValidator validator = new(); - Assert.True(result.IsValid); - } + [Fact] + public void Validate_ValidRequest_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024, Guid.NewGuid())); - [Fact] - public void Validate_ValidRequestWithoutOptionalFields_Passes() - { - var result = validator.Validate(new CreateAlbumRequest("My Album", null, null, Guid.NewGuid())); + Assert.True(result.IsValid); + } - Assert.True(result.IsValid); - } + [Fact] + public void Validate_ValidRequestWithoutOptionalFields_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("My Album", null, null, Guid.NewGuid())); - [Fact] - public void Validate_BlankTitle_Fails() - { - var result = validator.Validate(new CreateAlbumRequest("", null, null, Guid.NewGuid())); + Assert.True(result.IsValid); + } - Assert.False(result.IsValid); - Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title)); - } + [Fact] + public void Validate_BlankTitle_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("", null, null, Guid.NewGuid())); - [Fact] - public void Validate_TitleOverMaxLength_Fails() - { - var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null, Guid.NewGuid())); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title)); + } - Assert.False(result.IsValid); - } + [Fact] + public void Validate_TitleOverMaxLength_Fails() + { + var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null, Guid.NewGuid())); - [Fact] - public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear - 1, Guid.NewGuid())); + Assert.False(result.IsValid); + } - Assert.False(result.IsValid); - } + [Fact] + public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear - 1, Guid.NewGuid())); - [Fact] - public void Validate_ReleaseYearAtTheEarliestBoundary_Passes() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear, Guid.NewGuid())); + Assert.False(result.IsValid); + } - Assert.True(result.IsValid); - } + [Fact] + public void Validate_ReleaseYearAtTheEarliestBoundary_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear, Guid.NewGuid())); - [Fact] - public void Validate_ReleaseYearAtTheLatestBoundary_Passes() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear, Guid.NewGuid())); + Assert.True(result.IsValid); + } - Assert.True(result.IsValid); - } + [Fact] + public void Validate_ReleaseYearAtTheLatestBoundary_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear, Guid.NewGuid())); - [Fact] - public void Validate_ReleaseYearPastTheLatestBoundary_Fails() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear + 1, Guid.NewGuid())); + Assert.True(result.IsValid); + } - Assert.False(result.IsValid); - } + [Fact] + public void Validate_ReleaseYearPastTheLatestBoundary_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear + 1, Guid.NewGuid())); - [Fact] - public void Validate_EmptyPictureIntentId_Fails() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, null, Guid.Empty)); + Assert.False(result.IsValid); + } - Assert.False(result.IsValid); - Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.PictureIntentId)); + [Fact] + public void Validate_EmptyPictureIntentId_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, null, Guid.Empty)); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.PictureIntentId)); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs index 9643e18..59534b7 100644 --- a/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs +++ b/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs @@ -3,46 +3,47 @@ using Musify.Api.Validators.Tracks; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Api.Tests.Validators; - -public sealed class TrackRequestValidatorTests +namespace Musify.Api.Tests.Validators { - [Fact] - public void CreateTrack_ValidRequest_Passes() + public sealed class TrackRequestValidatorTests { - var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop, Genre.Rock]); + [Fact] + public void CreateTrack_ValidRequest_Passes() + { + var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop, Genre.Rock]); - Assert.True(new CreateTrackRequestValidator().Validate(request).IsValid); - } + Assert.True(new CreateTrackRequestValidator().Validate(request).IsValid); + } - [Fact] - public void CreateTrack_NoTags_Fails() - { - var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), []); + [Fact] + public void CreateTrack_NoTags_Fails() + { + var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), []); - Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid); - } + Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid); + } - [Fact] - public void CreateTrack_IncompatibleTags_Fails() - { - var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Classical, Genre.Metal]); + [Fact] + public void CreateTrack_IncompatibleTags_Fails() + { + var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Classical, Genre.Metal]); - Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid); - } + Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid); + } - [Theory] - [InlineData(-1)] - [InlineData(double.NaN)] - [InlineData(double.PositiveInfinity)] - public void RecordListeningProgress_InvalidSeconds_Fails(double seconds) - { - Assert.False(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(seconds)).IsValid); - } + [Theory] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void RecordListeningProgress_InvalidSeconds_Fails(double seconds) + { + Assert.False(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(seconds)).IsValid); + } - [Fact] - public void RecordListeningProgress_ZeroSeconds_Passes() - { - Assert.True(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(0)).IsValid); + [Fact] + public void RecordListeningProgress_ZeroSeconds_Passes() + { + Assert.True(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(0)).IsValid); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs index e9b5fa3..8a99892 100644 --- a/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs +++ b/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs @@ -5,45 +5,46 @@ using Musify.Api.Validators.Tracks; using Musify.Application.Configuration; using Xunit; -namespace Musify.Api.Tests.Validators; - -public sealed class UploadRequestValidatorTests +namespace Musify.Api.Tests.Validators { - private readonly UploadIntentConfiguration configuration = new() { MaxUploadBytes = 1_000 }; - - [Fact] - public void PictureUpload_ValidRequest_Passes() + public sealed class UploadRequestValidatorTests { - var validator = new PictureUploadRequestValidator(configuration); + private readonly UploadIntentConfiguration configuration = new() { MaxUploadBytes = 1_000 }; - var result = validator.Validate(new PictureUploadRequest("webp", "image/webp", 500)); + [Fact] + public void PictureUpload_ValidRequest_Passes() + { + var validator = new PictureUploadRequestValidator(configuration); - Assert.True(result.IsValid); - } + var result = validator.Validate(new PictureUploadRequest("webp", "image/webp", 500)); - [Theory] - [InlineData("exe", "image/webp", 500L)] - [InlineData("averyveryveryloongextension", "image/webp", 500L)] - [InlineData("we bp", "image/webp", 500L)] - [InlineData("webp", "text/html", 500L)] - [InlineData("webp", "image/webp", 0L)] - [InlineData("webp", "image/webp", 1_001L)] - public void PictureUpload_InvalidRequest_Fails(string fileType, string contentType, long size) - { - var validator = new PictureUploadRequestValidator(configuration); + Assert.True(result.IsValid); + } - var result = validator.Validate(new PictureUploadRequest(fileType, contentType, size)); + [Theory] + [InlineData("exe", "image/webp", 500L)] + [InlineData("averyveryveryloongextension", "image/webp", 500L)] + [InlineData("we bp", "image/webp", 500L)] + [InlineData("webp", "text/html", 500L)] + [InlineData("webp", "image/webp", 0L)] + [InlineData("webp", "image/webp", 1_001L)] + public void PictureUpload_InvalidRequest_Fails(string fileType, string contentType, long size) + { + var validator = new PictureUploadRequestValidator(configuration); - Assert.False(result.IsValid); - } + var result = validator.Validate(new PictureUploadRequest(fileType, contentType, size)); - [Fact] - public void TrackUpload_AudioTypesAreCheckedAgainstTheAudioAllowList() - { - var validator = new RequestTrackUploadUrlsRequestValidator(configuration); + Assert.False(result.IsValid); + } - Assert.True(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg", 10, 900)).IsValid); - Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "png", "image/png")).IsValid); - Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("mp3", "audio/mpeg", "mp3", "audio/mpeg")).IsValid); + [Fact] + public void TrackUpload_AudioTypesAreCheckedAgainstTheAudioAllowList() + { + var validator = new RequestTrackUploadUrlsRequestValidator(configuration); + + Assert.True(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg", 10, 900)).IsValid); + Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "png", "image/png")).IsValid); + Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("mp3", "audio/mpeg", "mp3", "audio/mpeg")).IsValid); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs index 1619700..dacfaaf 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs @@ -5,108 +5,109 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class AddTracksToAlbumCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private AddTracksToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_FirstTrack_AddsItAtNumberOne() + public sealed class AddTracksToAlbumCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - var track = TestEntities.Track(owner); - await SeedAsync(owner, album, track); + private AddTracksToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); - var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_FirstTrack_AddsItAtNumberOne() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + var track = TestEntities.Track(owner); + await SeedAsync(owner, album, track); - Assert.False(result.IsError); - var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Equal(1, link.TrackNumber); - } + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + Assert.False(result.IsError); + var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal(1, link.TrackNumber); + } - var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, Guid.NewGuid(), [track.Id]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, Guid.NewGuid(), [track.Id]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NotOwner_ReturnsForbidden() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var album = TestEntities.Album(owner.Id); - var track = TestEntities.Track(owner); - await SeedAsync(owner, stranger, album, track); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(stranger.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NotOwner_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var album = TestEntities.Album(owner.Id); + var track = TestEntities.Track(owner); + await SeedAsync(owner, stranger, album, track); - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(stranger.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - await SeedAsync(owner, album); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, album); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackOwnedByAnotherUser_ReturnsForbidden() - { - var owner = TestEntities.User(1, "owner"); - var otherOwner = TestEntities.User(2, "other"); - var album = TestEntities.Album(owner.Id); - var foreignTrack = TestEntities.Track(otherOwner); - await SeedAsync(owner, otherOwner, album, foreignTrack); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [foreignTrack.Id]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackOwnedByAnotherUser_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var otherOwner = TestEntities.User(2, "other"); + var album = TestEntities.Album(owner.Id); + var foreignTrack = TestEntities.Track(otherOwner); + await SeedAsync(owner, otherOwner, album, foreignTrack); - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [foreignTrack.Id]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlreadyInAlbum_IsSkipped() - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - var track = TestEntities.Track(owner); - var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }; - await SeedAsync(owner, album, track, link); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_AlreadyInAlbum_IsSkipped() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + var track = TestEntities.Track(owner); + var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }; + await SeedAsync(owner, album, track, link); - Assert.False(result.IsError); - Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlbumAlreadyHasTracks_AppendsAtNextNumber() - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - var firstTrack = TestEntities.Track(owner, "First"); - var secondTrack = TestEntities.Track(owner, "Second"); - var existingLink = new AlbumHasTrack { AlbumId = album.Id, TrackId = firstTrack.Id, TrackNumber = 3 }; - await SeedAsync(owner, album, firstTrack, secondTrack, existingLink); + Assert.False(result.IsError); + Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [secondTrack.Id]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_AlbumAlreadyHasTracks_AppendsAtNextNumber() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + var firstTrack = TestEntities.Track(owner, "First"); + var secondTrack = TestEntities.Track(owner, "Second"); + var existingLink = new AlbumHasTrack { AlbumId = album.Id, TrackId = firstTrack.Id, TrackNumber = 3 }; + await SeedAsync(owner, album, firstTrack, secondTrack, existingLink); - Assert.False(result.IsError); - var newLink = await Database.AlbumHasTracks.SingleAsync(l => l.TrackId == secondTrack.Id, TestContext.Current.CancellationToken); - Assert.Equal(4, newLink.TrackNumber); + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [secondTrack.Id]), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var newLink = await Database.AlbumHasTracks.SingleAsync(l => l.TrackId == secondTrack.Id, TestContext.Current.CancellationToken); + Assert.Equal(4, newLink.TrackNumber); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs index 5e5972c..fcf5d1d 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs @@ -6,69 +6,70 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); - - private CreateAlbumCommandHandler CreateHandler() => new( - eventBus, - Database, - new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), - NoOpLogger(), - TestConfigurations.Album()); - - [Fact] - public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent() + public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); - var owner = TestEntities.User(); - var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "cover.webp"); - await SeedAsync(owner, intent); + private CreateAlbumCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), + NoOpLogger(), + TestConfigurations.Album()); - var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024, intent.Id); + [Fact] + public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var owner = TestEntities.User(); + var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "cover.webp"); + await SeedAsync(owner, intent); - Assert.False(result.IsError); - Assert.Equal("My Album", result.Value.Title); - Assert.Equal("A description", result.Value.Description); - Assert.Equal(2024, result.Value.ReleaseYear); - Assert.Equal(owner.Id, result.Value.OwnerUserId); - Assert.Equal(0, result.Value.TrackCount); + var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024, intent.Id); - var stored = await Database.Albums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("MY ALBUM", stored.NormalizedTitle); - Assert.Equal("cover.webp", stored.Pictures?.OriginalName); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); - } + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UserDoesNotExist_ReturnsNotFound() - { - var command = new CreateAlbumCommand(404, "Orphan Album", null, null, Guid.NewGuid()); + Assert.False(result.IsError); + Assert.Equal("My Album", result.Value.Title); + Assert.Equal("A description", result.Value.Description); + Assert.Equal(2024, result.Value.ReleaseYear); + Assert.Equal(owner.Id, result.Value.OwnerUserId); + Assert.Equal(0, result.Value.TrackCount); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var stored = await Database.Albums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("MY ALBUM", stored.NormalizedTitle); + Assert.Equal("cover.webp", stored.Pictures?.OriginalName); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_UserDoesNotExist_ReturnsNotFound() + { + var command = new CreateAlbumCommand(404, "Orphan Album", null, null, Guid.NewGuid()); - [Fact] - public async Task Handle_PictureIntentNotFound_ReturnsError() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new CreateAlbumCommand(owner.Id, "My Album", null, null, Guid.NewGuid()); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PictureIntentNotFound_ReturnsError() + { + var owner = TestEntities.User(); + await SeedAsync(owner); - Assert.True(result.IsError); + var command = new CreateAlbumCommand(owner.Id, "My Album", null, null, Guid.NewGuid()); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs index f6ab013..d7ac89c 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs @@ -6,49 +6,50 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private readonly IEventBus eventBus = Substitute.For(); - - private DeleteAlbumCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); - - [Fact] - public async Task Handle_Owner_MarksAlbumAsRemoving() + public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - await SeedAsync(owner, album); + private readonly IEventBus eventBus = Substitute.For(); - var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken); + private DeleteAlbumCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); - Assert.False(result.IsError); - var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); - } + [Fact] + public async Task Handle_Owner_MarksAlbumAsRemoving() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, album); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new DeleteAlbumCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + Assert.False(result.IsError); + var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } - [Fact] - public async Task Handle_NotOwner_ReturnsForbiddenAndKeepsAlbum() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var album = TestEntities.Album(owner.Id); - await SeedAsync(owner, stranger, album); + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new DeleteAlbumCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken)); + [Fact] + public async Task Handle_NotOwner_ReturnsForbiddenAndKeepsAlbum() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, stranger, album); + + var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs index 798e73f..bb21b98 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs @@ -4,33 +4,34 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class GetAlbumByIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private GetAlbumByIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ExistingAlbum_ReturnsItWithTrackCountAndCovers() + public sealed class GetAlbumByIdQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - var track = TestEntities.Track(owner); - await SeedAsync(owner, album, track, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }); + private GetAlbumByIdQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetAlbumByIdQuery(album.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ExistingAlbum_ReturnsItWithTrackCountAndCovers() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + var track = TestEntities.Track(owner); + await SeedAsync(owner, album, track, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }); - Assert.False(result.IsError); - Assert.Equal(album.Id, result.Value.Id); - Assert.Equal(1, result.Value.TrackCount); - Assert.Equal([track.Id], result.Value.CoverTrackIds); - } + var result = await CreateHandler().Handle(new GetAlbumByIdQuery(album.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetAlbumByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(album.Id, result.Value.Id); + Assert.Equal(1, result.Value.TrackCount); + Assert.Equal([track.Id], result.Value.CoverTrackIds); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetAlbumByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs index 26beb6c..955d7ab 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs @@ -4,35 +4,36 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class GetAlbumTracksQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private GetAlbumTracksQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_AlbumWithTracks_ReturnsThemOrderedByTrackNumber() + public sealed class GetAlbumTracksQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - var second = TestEntities.Track(owner, "Second"); - var first = TestEntities.Track(owner, "First"); - await SeedAsync( - owner, album, second, first, - new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 }, - new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 }); + private GetAlbumTracksQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetAlbumTracksQuery(album.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_AlbumWithTracks_ReturnsThemOrderedByTrackNumber() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + var second = TestEntities.Track(owner, "Second"); + var first = TestEntities.Track(owner, "First"); + await SeedAsync( + owner, album, second, first, + new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 }, + new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 }); - Assert.False(result.IsError); - Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); - } + var result = await CreateHandler().Handle(new GetAlbumTracksQuery(album.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetAlbumTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetAlbumTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs index 244a269..93be0ed 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs @@ -3,31 +3,32 @@ using Musify.Application.Albums; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class GetAlbumsByUserIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private GetAlbumsByUserIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_UserWithAlbums_ReturnsOnlyTheirAlbums() + public sealed class GetAlbumsByUserIdQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(1, "owner"); - var other = TestEntities.User(2, "other"); - await SeedAsync(owner, other, TestEntities.Album(owner.Id, "Mine"), TestEntities.Album(other.Id, "Theirs")); + private GetAlbumsByUserIdQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(owner.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_UserWithAlbums_ReturnsOnlyTheirAlbums() + { + var owner = TestEntities.User(1, "owner"); + var other = TestEntities.User(2, "other"); + await SeedAsync(owner, other, TestEntities.Album(owner.Id, "Mine"), TestEntities.Album(other.Id, "Theirs")); - Assert.False(result.IsError); - var album = Assert.Single(result.Value.Items); - Assert.Equal("Mine", album.Title); - } + var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(owner.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(404, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + var album = Assert.Single(result.Value.Items); + Assert.Equal("Mine", album.Title); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(404, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs index b0df965..cda4557 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs @@ -2,42 +2,43 @@ using Musify.Application.Albums; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private GetAlbumsQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged() + public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.Album(owner.Id, "Alpha"), TestEntities.Album(owner.Id, "Beta")); + private GetAlbumsQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - Assert.Equal(2, result.TotalItemCount); - Assert.All(result.Items, item => Assert.NotNull(item)); - } + [Fact] + public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged() + { + var owner = TestEntities.User(); + await SeedAsync(owner, TestEntities.Album(owner.Id, "Alpha"), TestEntities.Album(owner.Id, "Beta")); - [Fact] - public async Task Handle_TitleFilter_ReturnsMatchingAlbumsOnly() - { - var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.Album(owner.Id, "Rock Classics"), TestEntities.Album(owner.Id, "Jazz Favorites")); + var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.Equal(2, result.TotalItemCount); + Assert.All(result.Items, item => Assert.NotNull(item)); + } - var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - var item = Assert.Single(result.Items); - Assert.Equal("Rock Classics", item.Title); - } + [Fact] + public async Task Handle_TitleFilter_ReturnsMatchingAlbumsOnly() + { + var owner = TestEntities.User(); + await SeedAsync(owner, TestEntities.Album(owner.Id, "Rock Classics"), TestEntities.Album(owner.Id, "Jazz Favorites")); - [Fact] - public async Task Handle_PageSizeSmallerThanTotal_ReportsHasNextPage() - { - var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.Album(owner.Id, "One"), TestEntities.Album(owner.Id, "Two"), TestEntities.Album(owner.Id, "Three")); + var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + var item = Assert.Single(result.Items); + Assert.Equal("Rock Classics", item.Title); + } - var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken); - Assert.Equal(2, result.Items.Count); - Assert.True(result.HasNextPage); + [Fact] + public async Task Handle_PageSizeSmallerThanTotal_ReportsHasNextPage() + { + var owner = TestEntities.User(); + await SeedAsync(owner, TestEntities.Album(owner.Id, "One"), TestEntities.Album(owner.Id, "Two"), TestEntities.Album(owner.Id, "Three")); + + var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken); + Assert.Equal(2, result.Items.Count); + Assert.True(result.HasNextPage); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs index df36f5b..2cc2427 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs @@ -3,72 +3,73 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private GetRecentlyListenedAlbumsQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ListenedTracks_ReturnsTheirAlbumsMostRecentFirst() + public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var olderAlbum = TestEntities.Album(owner.Id, "Older"); - var newerAlbum = TestEntities.Album(owner.Id, "Newer"); - var olderTrack = TestEntities.Track(owner, "In older album"); - var newerTrack = TestEntities.Track(owner, "In newer album"); - await SeedAsync( - owner, olderAlbum, newerAlbum, olderTrack, newerTrack, - new AlbumHasTrack { AlbumId = olderAlbum.Id, TrackId = olderTrack.Id, TrackNumber = 1 }, - new AlbumHasTrack { AlbumId = newerAlbum.Id, TrackId = newerTrack.Id, TrackNumber = 1 }, - TestEntities.ListeningHistory(owner.Id, olderTrack.Id, DateTime.UtcNow.AddDays(-2)), - TestEntities.ListeningHistory(owner.Id, newerTrack.Id, DateTime.UtcNow.AddDays(-1))); + private GetRecentlyListenedAlbumsQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); - Assert.Equal(["Newer", "Older"], result.Select(album => album.Title)); - } + [Fact] + public async Task Handle_ListenedTracks_ReturnsTheirAlbumsMostRecentFirst() + { + var owner = TestEntities.User(); + var olderAlbum = TestEntities.Album(owner.Id, "Older"); + var newerAlbum = TestEntities.Album(owner.Id, "Newer"); + var olderTrack = TestEntities.Track(owner, "In older album"); + var newerTrack = TestEntities.Track(owner, "In newer album"); + await SeedAsync( + owner, olderAlbum, newerAlbum, olderTrack, newerTrack, + new AlbumHasTrack { AlbumId = olderAlbum.Id, TrackId = olderTrack.Id, TrackNumber = 1 }, + new AlbumHasTrack { AlbumId = newerAlbum.Id, TrackId = newerTrack.Id, TrackNumber = 1 }, + TestEntities.ListeningHistory(owner.Id, olderTrack.Id, DateTime.UtcNow.AddDays(-2)), + TestEntities.ListeningHistory(owner.Id, newerTrack.Id, DateTime.UtcNow.AddDays(-1))); - [Fact] - public async Task Handle_NoListeningHistory_ReturnsEmptyList() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); + Assert.Equal(["Newer", "Older"], result.Select(album => album.Title)); + } - var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); - Assert.Empty(result); - } + [Fact] + public async Task Handle_NoListeningHistory_ReturnsEmptyList() + { + var owner = TestEntities.User(); + await SeedAsync(owner); - [Fact] - public async Task Handle_LimitLowerThanCandidates_ReturnsOnlyThatManyAlbums() - { - var owner = TestEntities.User(); - var albums = Enumerable.Range(1, 3).Select(i => TestEntities.Album(owner.Id, $"Album {i}")).ToList(); - var tracks = albums.Select((_, i) => TestEntities.Track(owner, $"Track {i}")).ToList(); - var links = albums.Zip(tracks, (album, track) => new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }).ToList(); - var histories = tracks.Select((track, i) => TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddMinutes(-i))).ToList(); - await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]); + var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); + Assert.Empty(result); + } - var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken); - Assert.Equal(2, result.Count); - } + [Fact] + public async Task Handle_LimitLowerThanCandidates_ReturnsOnlyThatManyAlbums() + { + var owner = TestEntities.User(); + var albums = Enumerable.Range(1, 3).Select(i => TestEntities.Album(owner.Id, $"Album {i}")).ToList(); + var tracks = albums.Select((_, i) => TestEntities.Track(owner, $"Track {i}")).ToList(); + var links = albums.Zip(tracks, (album, track) => new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }).ToList(); + var histories = tracks.Select((track, i) => TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddMinutes(-i))).ToList(); + await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]); - [Fact] - public async Task Handle_UncountedListens_AreIgnored() - { - var owner = TestEntities.User(); - var countedAlbum = TestEntities.Album(owner.Id, "Counted album"); - var skippedAlbum = TestEntities.Album(owner.Id, "Skipped album"); - var countedTrack = TestEntities.Track(owner, "In counted"); - var skippedTrack = TestEntities.Track(owner, "In skipped"); - await SeedAsync( - owner, countedAlbum, skippedAlbum, countedTrack, skippedTrack, - new AlbumHasTrack { AlbumId = countedAlbum.Id, TrackId = countedTrack.Id, TrackNumber = 1 }, - new AlbumHasTrack { AlbumId = skippedAlbum.Id, TrackId = skippedTrack.Id, TrackNumber = 1 }, - TestEntities.ListeningHistory(owner.Id, countedTrack.Id, DateTime.UtcNow.AddDays(-1), playedSeconds: 120), - TestEntities.ListeningHistory(owner.Id, skippedTrack.Id, DateTime.UtcNow, playedSeconds: 1, isCounted: false)); + var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken); + Assert.Equal(2, result.Count); + } - var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_UncountedListens_AreIgnored() + { + var owner = TestEntities.User(); + var countedAlbum = TestEntities.Album(owner.Id, "Counted album"); + var skippedAlbum = TestEntities.Album(owner.Id, "Skipped album"); + var countedTrack = TestEntities.Track(owner, "In counted"); + var skippedTrack = TestEntities.Track(owner, "In skipped"); + await SeedAsync( + owner, countedAlbum, skippedAlbum, countedTrack, skippedTrack, + new AlbumHasTrack { AlbumId = countedAlbum.Id, TrackId = countedTrack.Id, TrackNumber = 1 }, + new AlbumHasTrack { AlbumId = skippedAlbum.Id, TrackId = skippedTrack.Id, TrackNumber = 1 }, + TestEntities.ListeningHistory(owner.Id, countedTrack.Id, DateTime.UtcNow.AddDays(-1), playedSeconds: 120), + TestEntities.ListeningHistory(owner.Id, skippedTrack.Id, DateTime.UtcNow, playedSeconds: 1, isCounted: false)); - Assert.Equal(["Counted album"], result.Select(album => album.Title)); + var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); + + Assert.Equal(["Counted album"], result.Select(album => album.Title)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs index 60be72f..f5e6a85 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs @@ -5,68 +5,69 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private RemoveTrackFromAlbumCommandHandler CreateHandler() => - new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_TrackInAlbum_RemovesItAndRenumbersTheRest() + public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - var first = TestEntities.Track(owner, "First"); - var second = TestEntities.Track(owner, "Second"); - var third = TestEntities.Track(owner, "Third"); - await SeedAsync( - owner, album, first, second, third, - new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 }, - new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 }, - new AlbumHasTrack { AlbumId = album.Id, TrackId = third.Id, TrackNumber = 3 }); + private RemoveTrackFromAlbumCommandHandler CreateHandler() => + new(Database, NoOpLogger()); - var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, second.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackInAlbum_RemovesItAndRenumbersTheRest() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + var third = TestEntities.Track(owner, "Third"); + await SeedAsync( + owner, album, first, second, third, + new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 }, + new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 }, + new AlbumHasTrack { AlbumId = album.Id, TrackId = third.Id, TrackNumber = 3 }); - Assert.False(result.IsError); - var remaining = await Database.AlbumHasTracks - .Where(link => link.AlbumId == album.Id) - .OrderBy(link => link.TrackNumber) - .ToListAsync(TestContext.Current.CancellationToken); - Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId)); - Assert.Equal([1, 2], remaining.Select(link => link.TrackNumber)); - } + var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, second.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + var remaining = await Database.AlbumHasTracks + .Where(link => link.AlbumId == album.Id) + .OrderBy(link => link.TrackNumber) + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId)); + Assert.Equal([1, 2], remaining.Select(link => link.TrackNumber)); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NotOwner_ReturnsForbidden() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var album = TestEntities.Album(owner.Id); - await SeedAsync(owner, stranger, album); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NotOwner_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, stranger, album); - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackNotInAlbum_ReturnsNotFound() - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - await SeedAsync(owner, album); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackNotInAlbum_ReturnsNotFound() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, album); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs index 98da07e..711218b 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs @@ -8,70 +8,71 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private readonly IStorageService storageService = Substitute.For(); - - private RequestAlbumPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBase { - storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns("https://storage.musify.test/presigned-upload"); + private readonly IStorageService storageService = Substitute.For(); - var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); + private RequestAlbumPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + { + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); - return new RequestAlbumPictureUploadCommandHandler( - new UploadIntentIssuer( - Database, - storageService, - new UploadIntentValidator(Database, storageService, config), - TestConfigurations.Storage(), - config, - NoOpLogger()), - TestConfigurations.Album()); - } + var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); - [Fact] - public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent() - { - var user = TestEntities.User(); - await SeedAsync(user); + return new RequestAlbumPictureUploadCommandHandler( + new UploadIntentIssuer( + Database, + storageService, + new UploadIntentValidator(Database, storageService, config), + TestConfigurations.Storage(), + config, + NoOpLogger()), + TestConfigurations.Album()); + } - var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp"); + [Fact] + public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent() + { + var user = TestEntities.User(); + await SeedAsync(user); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp"); - Assert.False(result.IsError); - Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl); - Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var command = new RequestAlbumPictureUploadCommand(404, "webp", "image/webp"); + Assert.False(result.IsError); + Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl); + Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new RequestAlbumPictureUploadCommand(404, "webp", "image/webp"); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent() - { - var config = TestConfigurations.UploadIntent(); - config.MaxActiveUploadBytesPerUser = 100; + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var user = TestEntities.User(); - await SeedAsync(user); + [Fact] + public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent() + { + var config = TestConfigurations.UploadIntent(); + config.MaxActiveUploadBytesPerUser = 100; - var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000); + var user = TestEntities.User(); + await SeedAsync(user); - var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken); + var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000); - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs index 41916a3..feb5986 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs @@ -6,86 +6,87 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); - - private UpdateAlbumCommandHandler CreateHandler() => new( - eventBus, - Database, - new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), - NoOpLogger(), - TestConfigurations.Album()); - - [Fact] - public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear() + public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020); - await SeedAsync(owner, album); + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); - var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025, null); + private UpdateAlbumCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), + NoOpLogger(), + TestConfigurations.Album()); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020); + await SeedAsync(owner, album); - Assert.False(result.IsError); - Assert.Equal("New Title", result.Value.Title); - Assert.Equal("New description", result.Value.Description); - Assert.Equal(2025, result.Value.ReleaseYear); + var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025, null); - var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("NEW TITLE", stored.NormalizedTitle); - } + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ValidPictureIntent_UpdatesPictureAndPublishesSourceEvent() - { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); + Assert.False(result.IsError); + Assert.Equal("New Title", result.Value.Title); + Assert.Equal("New description", result.Value.Description); + Assert.Equal(2025, result.Value.ReleaseYear); - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id, title: "Old Title"); - var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "new-cover.webp"); - await SeedAsync(owner, album, intent); + var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("NEW TITLE", stored.NormalizedTitle); + } - var command = new UpdateAlbumCommand(owner.Id, album.Id, "Old Title", null, null, intent.Id); + [Fact] + public async Task Handle_ValidPictureIntent_UpdatesPictureAndPublishesSourceEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id, title: "Old Title"); + var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "new-cover.webp"); + await SeedAsync(owner, album, intent); - Assert.False(result.IsError); - var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); - } + var command = new UpdateAlbumCommand(owner.Id, album.Id, "Old Title", null, null, intent.Id); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null, null); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + Assert.False(result.IsError); + var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null, null); - [Fact] - public async Task Handle_NotOwner_ReturnsForbidden() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var album = TestEntities.Album(owner.Id); - await SeedAsync(owner, stranger, album); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null, null); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NotOwner_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, stranger, album); - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null, null); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs b/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs index bc45a0b..fb11260 100644 --- a/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs +++ b/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs @@ -2,29 +2,30 @@ using Musify.Application.Configuration; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Configuration; - -public sealed class GetPlaybackPublicConfigTests +namespace Musify.Application.Tests.Configuration { - [Fact] - public async Task Handle_ReflectsPlaybackConfiguration() + public sealed class GetPlaybackPublicConfigTests { - var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); - var handler = new GetPlaybackPublicConfigQueryHandler(playback); + [Fact] + public async Task Handle_ReflectsPlaybackConfiguration() + { + var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); + var handler = new GetPlaybackPublicConfigQueryHandler(playback); - var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken); + var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken); - Assert.True(response.AllowAnonymousListening); - Assert.Equal(30, response.AnonymousFragmentSeconds); - } + Assert.True(response.AllowAnonymousListening); + Assert.Equal(30, response.AnonymousFragmentSeconds); + } - [Fact] - public async Task Handle_DefaultsToDisabled() - { - var handler = new GetPlaybackPublicConfigQueryHandler(TestConfigurations.Playback()); + [Fact] + public async Task Handle_DefaultsToDisabled() + { + var handler = new GetPlaybackPublicConfigQueryHandler(TestConfigurations.Playback()); - var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken); + var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken); - Assert.False(response.AllowAnonymousListening); + Assert.False(response.AllowAnonymousListening); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Configuration/PicturesConfigurationTests.cs b/backend/Tests/Musify.Application.Tests/Configuration/PicturesConfigurationTests.cs index d2d0603..2c34e5f 100644 --- a/backend/Tests/Musify.Application.Tests/Configuration/PicturesConfigurationTests.cs +++ b/backend/Tests/Musify.Application.Tests/Configuration/PicturesConfigurationTests.cs @@ -1,50 +1,51 @@ using Musify.Application.Configuration; using Xunit; -namespace Musify.Application.Tests.Configuration; - -public sealed class PicturesConfigurationTests +namespace Musify.Application.Tests.Configuration { - [Fact] - public void AlbumRoutes_BuildTheSameKeysAsBefore() + public sealed class PicturesConfigurationTests { - var routes = new AlbumConfiguration().Routes; + [Fact] + public void AlbumRoutes_BuildTheSameKeysAsBefore() + { + var routes = new AlbumConfiguration().Routes; - Assert.Equal("Albums/SmallPictures/a.webp", routes.BuildPicturePath(PictureSize.Small, "a.webp")); - Assert.Equal("Albums/MediumPictures/a.webp", routes.BuildPicturePath(PictureSize.Medium, "a.webp")); - Assert.Equal("Albums/LargePictures/a.webp", routes.BuildPicturePath(PictureSize.Large, "a.webp")); - Assert.Equal("uploads/7/Albums/OriginalPictures/a.webp", routes.BuildOriginalPicturePath(7, "a.webp")); - Assert.Equal("temp/7/Albums/obj.webp", routes.BuildTempPath("temp", 7, "obj.webp")); + Assert.Equal("Albums/SmallPictures/a.webp", routes.BuildPicturePath(PictureSize.Small, "a.webp")); + Assert.Equal("Albums/MediumPictures/a.webp", routes.BuildPicturePath(PictureSize.Medium, "a.webp")); + Assert.Equal("Albums/LargePictures/a.webp", routes.BuildPicturePath(PictureSize.Large, "a.webp")); + Assert.Equal("uploads/7/Albums/OriginalPictures/a.webp", routes.BuildOriginalPicturePath(7, "a.webp")); + Assert.Equal("temp/7/Albums/obj.webp", routes.BuildTempPath("temp", 7, "obj.webp")); + } + + [Fact] + public void TrackRoutes_KeepTheTrackFolderLayout() + { + var routes = new TrackConfiguration().Routes; + + Assert.Equal("Tracks/ProcessedAudios/folder", routes.BuildProcessedAudioPath("folder")); + Assert.Equal("uploads/7/Tracks/OriginalAudios/song.mp3", routes.BuildOriginalAudioPath(7, "song.mp3")); + Assert.Equal("Tracks/PresetSmallPicture.webp", routes.PresetSmallPicturePath); + } + + [Fact] + public void ToImageSizes_UsesTheRoutesFolders() + { + var configuration = new PlayListConfiguration(); + + var sizes = configuration.PicturesSizes.ToImageSizes(configuration.Routes); + + Assert.Equal("PlayLists/SmallPictures", sizes.Small.SavePath); + Assert.Equal(128, sizes.Small.Width); + Assert.Equal(512, sizes.Large.Width); + } + + [Theory] + [InlineData("small", PictureSize.Small)] + [InlineData("LARGE", PictureSize.Large)] + [InlineData("medium", PictureSize.Medium)] + [InlineData("whatever", PictureSize.Medium)] + [InlineData(null, PictureSize.Medium)] + public void PictureSizeParser_DefaultsToMedium(string? value, PictureSize expected) => + Assert.Equal(expected, PictureSizeParser.Parse(value)); } - - [Fact] - public void TrackRoutes_KeepTheTrackFolderLayout() - { - var routes = new TrackConfiguration().Routes; - - Assert.Equal("Tracks/ProcessedAudios/folder", routes.BuildProcessedAudioPath("folder")); - Assert.Equal("uploads/7/Tracks/OriginalAudios/song.mp3", routes.BuildOriginalAudioPath(7, "song.mp3")); - Assert.Equal("Tracks/PresetSmallPicture.webp", routes.PresetSmallPicturePath); - } - - [Fact] - public void ToImageSizes_UsesTheRoutesFolders() - { - var configuration = new PlayListConfiguration(); - - var sizes = configuration.PicturesSizes.ToImageSizes(configuration.Routes); - - Assert.Equal("PlayLists/SmallPictures", sizes.Small.SavePath); - Assert.Equal(128, sizes.Small.Width); - Assert.Equal(512, sizes.Large.Width); - } - - [Theory] - [InlineData("small", PictureSize.Small)] - [InlineData("LARGE", PictureSize.Large)] - [InlineData("medium", PictureSize.Medium)] - [InlineData("whatever", PictureSize.Medium)] - [InlineData(null, PictureSize.Medium)] - public void PictureSizeParser_DefaultsToMedium(string? value, PictureSize expected) => - Assert.Equal(expected, PictureSizeParser.Parse(value)); } diff --git a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs index 9e63b58..3b1473c 100644 --- a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs @@ -3,30 +3,31 @@ using Musify.Application.Genres.Responses; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.Genres; - -public sealed class GetAvailableGenresQueryHandlerTests +namespace Musify.Application.Tests.Genres { - private static async Task> RunAsync() => - await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken); - - [Fact] - public async Task Handle_ReturnsEveryGenre() + public sealed class GetAvailableGenresQueryHandlerTests { - var result = await RunAsync(); + private static async Task> RunAsync() => + await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken); - Assert.Equal(Enum.GetValues(), result.Select(r => r.Genre)); - } + [Fact] + public async Task Handle_ReturnsEveryGenre() + { + var result = await RunAsync(); - [Fact] - public async Task Handle_ListsIncompatibleGenresSymmetrically() - { - var result = await RunAsync(); + Assert.Equal(Enum.GetValues(), result.Select(r => r.Genre)); + } - var metal = result.Single(r => r.Genre == Genre.Metal); - var lofi = result.Single(r => r.Genre == Genre.Lofi); - Assert.Contains(Genre.Lofi, metal.IncompatibleWith); - Assert.Contains(Genre.Metal, lofi.IncompatibleWith); - Assert.Empty(result.Single(r => r.Genre == Genre.Pop).IncompatibleWith); + [Fact] + public async Task Handle_ListsIncompatibleGenresSymmetrically() + { + var result = await RunAsync(); + + var metal = result.Single(r => r.Genre == Genre.Metal); + var lofi = result.Single(r => r.Genre == Genre.Lofi); + Assert.Contains(Genre.Lofi, metal.IncompatibleWith); + Assert.Contains(Genre.Metal, lofi.IncompatibleWith); + Assert.Empty(result.Single(r => r.Genre == Genre.Pop).IncompatibleWith); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs index cb76ee5..4086ba1 100644 --- a/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs @@ -3,35 +3,36 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.Genres; - -public sealed class GetGenresQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Genres { - private GetGenresQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_NoTracks_ReturnsEmpty() + public sealed class GetGenresQueryHandlerTests : HandlerTestBase { - var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); + private GetGenresQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_NoTracks_ReturnsEmpty() + { + var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); + + Assert.Empty(result); + } + + [Fact] + public async Task Handle_ReturnsOnlyGenresInUse_MostPopulatedFirst() + { + var owner = TestEntities.User(); + var rockA = TestEntities.Track(owner, "Rock A", tags: [Genre.Rock]); + var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]); + var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]); + await SeedAsync( + owner, rockA, rockB, jazz); + + var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); + + Assert.Equal( + [(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)], + result.Select(r => (r.Genre, r.TrackCount))); + } - Assert.Empty(result); } - - [Fact] - public async Task Handle_ReturnsOnlyGenresInUse_MostPopulatedFirst() - { - var owner = TestEntities.User(); - var rockA = TestEntities.Track(owner, "Rock A", tags: [Genre.Rock]); - var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]); - var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]); - await SeedAsync( - owner, rockA, rockB, jazz); - - var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); - - Assert.Equal( - [(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)], - result.Select(r => (r.Genre, r.TrackCount))); - } - } diff --git a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs index 9234f5b..30f1782 100644 --- a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs @@ -3,46 +3,47 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Likes; - -public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Likes { - private GetLikedTracksQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_MultipleLikes_ReturnsMostRecentFirst() + public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var older = TestEntities.Track(owner, "Older like"); - var newer = TestEntities.Track(owner, "Newer like"); - await SeedAsync( - owner, older, newer, - new TrackLike { UserId = owner.Id, TrackId = older.Id, CreatedAt = DateTime.UtcNow.AddMinutes(-10) }, - new TrackLike { UserId = owner.Id, TrackId = newer.Id, CreatedAt = DateTime.UtcNow }); + private GetLikedTracksQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); - Assert.Equal(["Newer like", "Older like"], result.Items.Select(track => track.Title)); - } + [Fact] + public async Task Handle_MultipleLikes_ReturnsMostRecentFirst() + { + var owner = TestEntities.User(); + var older = TestEntities.Track(owner, "Older like"); + var newer = TestEntities.Track(owner, "Newer like"); + await SeedAsync( + owner, older, newer, + new TrackLike { UserId = owner.Id, TrackId = older.Id, CreatedAt = DateTime.UtcNow.AddMinutes(-10) }, + new TrackLike { UserId = owner.Id, TrackId = newer.Id, CreatedAt = DateTime.UtcNow }); - [Fact] - public async Task Handle_NoLikes_ReturnsEmpty() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); + Assert.Equal(["Newer like", "Older like"], result.Items.Select(track => track.Title)); + } - var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); - Assert.Empty(result.Items); - } + [Fact] + public async Task Handle_NoLikes_ReturnsEmpty() + { + var owner = TestEntities.User(); + await SeedAsync(owner); - [Fact] - public async Task Handle_OtherUsersLikes_AreExcluded() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var track = TestEntities.Track(owner); - await SeedAsync(owner, stranger, track, new TrackLike { UserId = stranger.Id, TrackId = track.Id }); + var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); + Assert.Empty(result.Items); + } - var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); - Assert.Empty(result.Items); + [Fact] + public async Task Handle_OtherUsersLikes_AreExcluded() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var track = TestEntities.Track(owner); + await SeedAsync(owner, stranger, track, new TrackLike { UserId = stranger.Id, TrackId = track.Id }); + + var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); + Assert.Empty(result.Items); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs index 1234a57..f03b174 100644 --- a/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs @@ -4,52 +4,53 @@ using Musify.Application.Likes; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Likes; - -public sealed class ToggleTrackLikeCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Likes { - private ToggleTrackLikeCommandHandler CreateHandler() => - new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_TrackNotLiked_LikesIt() + public sealed class ToggleTrackLikeCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + private ToggleTrackLikeCommandHandler CreateHandler() => + new(Database, NoOpLogger()); - var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackNotLiked_LikesIt() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - Assert.False(result.IsError); - Assert.True(result.Value); - Assert.Single(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle( + new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackAlreadyLiked_UnlikesIt() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track, new Musify.Domain.Entities.TrackLike { UserId = owner.Id, TrackId = track.Id }); + Assert.False(result.IsError); + Assert.True(result.Value); + Assert.Single(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackAlreadyLiked_UnlikesIt() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track, new Musify.Domain.Entities.TrackLike { UserId = owner.Id, TrackId = track.Id }); - Assert.False(result.IsError); - Assert.False(result.Value); - Assert.Empty(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle( + new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + Assert.False(result.IsError); + Assert.False(result.Value); + Assert.Empty(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + await SeedAsync(owner); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + var result = await CreateHandler().Handle( + new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs index 0a30e30..47ee9e6 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs @@ -4,78 +4,79 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.Mixes; - -public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Mixes { - private GenerateMixesForUserCommandHandler CreateHandler() => new( - Database, TestConfigurations.Mix(), NoOpLogger()); - - [Fact] - public async Task Handle_NoTracks_DoesNotCreateAnyMix() + public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase { - var user = TestEntities.User(); - await SeedAsync(user); + private GenerateMixesForUserCommandHandler CreateHandler() => new( + Database, TestConfigurations.Mix(), NoOpLogger()); - await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken)); - } + [Fact] + public async Task Handle_NoTracks_DoesNotCreateAnyMix() + { + var user = TestEntities.User(); + await SeedAsync(user); - [Fact] - public async Task Handle_AllTracksAlreadyListenedTo_CreatesOnlyTheDailyMix() - { - var user = TestEntities.User(); - var track = TestEntities.Track(user); - await SeedAsync( - user, track, - TestEntities.ListeningHistory(user.Id, track.Id)); + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken)); + } - await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.Single(mixes); - Assert.Equal(MixKind.Daily, mixes[0].Kind); - } + [Fact] + public async Task Handle_AllTracksAlreadyListenedTo_CreatesOnlyTheDailyMix() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id)); - [Fact] - public async Task Handle_UnheardTracksInLibrary_CreatesDiscoveryAndDailyMixes_AndReplacesPreviousMixes() - { - var user = TestEntities.User(); - var listenedTrack = TestEntities.Track(user, "Listened Track"); - var unheardTrack = TestEntities.Track(user, "Unheard Track"); + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); + Assert.Single(mixes); + Assert.Equal(MixKind.Daily, mixes[0].Kind); + } - var staleMix = TestEntities.Mix(user.Id, MixKind.Daily); - var staleItem = TestEntities.MixItem(staleMix.Id, listenedTrack.Id); + [Fact] + public async Task Handle_UnheardTracksInLibrary_CreatesDiscoveryAndDailyMixes_AndReplacesPreviousMixes() + { + var user = TestEntities.User(); + var listenedTrack = TestEntities.Track(user, "Listened Track"); + var unheardTrack = TestEntities.Track(user, "Unheard Track"); - await SeedAsync( - user, listenedTrack, unheardTrack, staleMix, staleItem, - TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); + var staleMix = TestEntities.Mix(user.Id, MixKind.Daily); + var staleItem = TestEntities.MixItem(staleMix.Id, listenedTrack.Id); - await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.DoesNotContain(mixes, mix => mix.Id == staleMix.Id); - Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery); - Assert.Contains(mixes, mix => mix.Kind == MixKind.Daily); - Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken)); + await SeedAsync( + user, listenedTrack, unheardTrack, staleMix, staleItem, + TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); - var discoveryMix = mixes.Single(mix => mix.Kind == MixKind.Discovery); - var discoveryItems = await Database.MixItems - .Where(item => item.MixId == discoveryMix.Id) - .ToListAsync(TestContext.Current.CancellationToken); - Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId)); - } + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); + Assert.DoesNotContain(mixes, mix => mix.Id == staleMix.Id); + Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery); + Assert.Contains(mixes, mix => mix.Kind == MixKind.Daily); + Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken)); - [Fact] - public async Task Handle_TrackWithOnlyUncountedListens_IsTreatedAsUnheard() - { - var user = TestEntities.User(); - var skipped = TestEntities.Track(user, "Only skipped"); - await SeedAsync( - user, skipped, - TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false)); + var discoveryMix = mixes.Single(mix => mix.Kind == MixKind.Discovery); + var discoveryItems = await Database.MixItems + .Where(item => item.MixId == discoveryMix.Id) + .ToListAsync(TestContext.Current.CancellationToken); + Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId)); + } - await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackWithOnlyUncountedListens_IsTreatedAsUnheard() + { + var user = TestEntities.User(); + var skipped = TestEntities.Track(user, "Only skipped"); + await SeedAsync( + user, skipped, + TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false)); - var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery); + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + + var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); + Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs index 25274c3..2d456d8 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs @@ -4,47 +4,48 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.Mixes; - -public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Mixes { - private GetMixByIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_OwnedMix_ReturnsItWithOrderedItems() + public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var firstTrack = TestEntities.Track(owner, "First"); - var secondTrack = TestEntities.Track(owner, "Second"); - var mix = TestEntities.Mix(owner.Id, MixKind.Daily); - var second = TestEntities.MixItem(mix.Id, secondTrack.Id, position: 1); - var first = TestEntities.MixItem(mix.Id, firstTrack.Id, position: 0); - await SeedAsync(owner, firstTrack, secondTrack, mix, second, first); + private GetMixByIdQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_OwnedMix_ReturnsItWithOrderedItems() + { + var owner = TestEntities.User(); + var firstTrack = TestEntities.Track(owner, "First"); + var secondTrack = TestEntities.Track(owner, "Second"); + var mix = TestEntities.Mix(owner.Id, MixKind.Daily); + var second = TestEntities.MixItem(mix.Id, secondTrack.Id, position: 1); + var first = TestEntities.MixItem(mix.Id, firstTrack.Id, position: 0); + await SeedAsync(owner, firstTrack, secondTrack, mix, second, first); - Assert.False(result.IsError); - Assert.Equal(MixKind.Daily, result.Value.Kind); - Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title)); - } + var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_MixMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetMixByIdQuery(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(MixKind.Daily, result.Value.Kind); + Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title)); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_MixMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetMixByIdQuery(1, Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_MixBelongsToAnotherUser_ReturnsNotFound() - { - var owner = TestEntities.User(1, "owner"); - var mix = TestEntities.Mix(owner.Id); - await SeedAsync(owner, mix); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new GetMixByIdQuery(2, mix.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_MixBelongsToAnotherUser_ReturnsNotFound() + { + var owner = TestEntities.User(1, "owner"); + var mix = TestEntities.Mix(owner.Id); + await SeedAsync(owner, mix); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + var result = await CreateHandler().Handle(new GetMixByIdQuery(2, mix.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs index f46344f..baa8c34 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs @@ -3,33 +3,34 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.Mixes; - -public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Mixes { - private GetMixesByUserIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ReturnsOnlyThatUsersMixesOrderedByPosition() + public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(1, "owner"); - var other = TestEntities.User(2, "other"); - var second = TestEntities.Mix(owner.Id, MixKind.Daily, position: 1); - var first = TestEntities.Mix(owner.Id, MixKind.Discovery, position: 0); - var theirs = TestEntities.Mix(other.Id); - await SeedAsync(owner, other, second, first, theirs); + private GetMixesByUserIdQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - Assert.Equal([MixKind.Discovery, MixKind.Daily], result.Select(mix => mix.Kind)); - } + [Fact] + public async Task Handle_ReturnsOnlyThatUsersMixesOrderedByPosition() + { + var owner = TestEntities.User(1, "owner"); + var other = TestEntities.User(2, "other"); + var second = TestEntities.Mix(owner.Id, MixKind.Daily, position: 1); + var first = TestEntities.Mix(owner.Id, MixKind.Discovery, position: 0); + var theirs = TestEntities.Mix(other.Id); + await SeedAsync(owner, other, second, first, theirs); - [Fact] - public async Task Handle_NoMixes_ReturnsEmptyList() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + Assert.Equal([MixKind.Discovery, MixKind.Daily], result.Select(mix => mix.Kind)); + } - var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - Assert.Empty(result); + [Fact] + public async Task Handle_NoMixes_ReturnsEmptyList() + { + var owner = TestEntities.User(); + await SeedAsync(owner); + + var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + Assert.Empty(result); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs index 63a3c8f..386627f 100644 --- a/backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs @@ -5,100 +5,101 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.Pictures; - -public sealed class GetCoverQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Pictures { - private GetCoverQueryHandler CreateHandler() => new( - Database, - TestConfigurations.Storage("covers-bucket"), - TestConfigurations.Track(), - TestConfigurations.Album(), - TestConfigurations.PlayList()); - - [Theory] - [InlineData(PictureSize.Small, "small.webp")] - [InlineData(PictureSize.Medium, "medium.webp")] - [InlineData(PictureSize.Large, "large.webp")] - public async Task Handle_ProcessedTrack_ReturnsRequestedSize(PictureSize size, string expectedFileName) + public sealed class GetCoverQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + private GetCoverQueryHandler CreateHandler() => new( + Database, + TestConfigurations.Storage("covers-bucket"), + TestConfigurations.Track(), + TestConfigurations.Album(), + TestConfigurations.PlayList()); - var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, size), TestContext.Current.CancellationToken); + [Theory] + [InlineData(PictureSize.Small, "small.webp")] + [InlineData(PictureSize.Medium, "medium.webp")] + [InlineData(PictureSize.Large, "large.webp")] + public async Task Handle_ProcessedTrack_ReturnsRequestedSize(PictureSize size, string expectedFileName) + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - Assert.False(result.IsError); - Assert.Equal("covers-bucket", result.Value.Bucket); - Assert.EndsWith(expectedFileName, result.Value.Key); - Assert.Equal("image/webp", result.Value.ContentType); - } + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, size), TestContext.Current.CancellationToken); - [Theory] - [InlineData(PictureSize.Small, "small.webp")] - [InlineData(PictureSize.Medium, "medium.webp")] - [InlineData(PictureSize.Large, "large.webp")] - public async Task Handle_ProcessedAlbum_ReturnsRequestedSize(PictureSize size, string expectedFileName) - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - await SeedAsync(owner, album); + Assert.False(result.IsError); + Assert.Equal("covers-bucket", result.Value.Bucket); + Assert.EndsWith(expectedFileName, result.Value.Key); + Assert.Equal("image/webp", result.Value.ContentType); + } - var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, size), TestContext.Current.CancellationToken); + [Theory] + [InlineData(PictureSize.Small, "small.webp")] + [InlineData(PictureSize.Medium, "medium.webp")] + [InlineData(PictureSize.Large, "large.webp")] + public async Task Handle_ProcessedAlbum_ReturnsRequestedSize(PictureSize size, string expectedFileName) + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, album); - Assert.False(result.IsError); - Assert.EndsWith(expectedFileName, result.Value.Key); - } + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, size), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ProcessedPlayList_ReturnsRequestedSize() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); + Assert.False(result.IsError); + Assert.EndsWith(expectedFileName, result.Value.Key); + } - var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.PlayList, playList.Id, PictureSize.Large), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ProcessedPlayList_ReturnsRequestedSize() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); - Assert.False(result.IsError); - Assert.EndsWith("large.webp", result.Value.Key); - } + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.PlayList, playList.Id, PictureSize.Large), TestContext.Current.CancellationToken); - [Theory] - [InlineData(CoverOwner.Track)] - [InlineData(CoverOwner.Album)] - [InlineData(CoverOwner.PlayList)] - public async Task Handle_EntityMissing_ReturnsNotFound(CoverOwner owner) - { - var result = await CreateHandler().Handle(new GetCoverQuery(owner, Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.EndsWith("large.webp", result.Value.Key); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Theory] + [InlineData(CoverOwner.Track)] + [InlineData(CoverOwner.Album)] + [InlineData(CoverOwner.PlayList)] + public async Task Handle_EntityMissing_ReturnsNotFound(CoverOwner owner) + { + var result = await CreateHandler().Handle(new GetCoverQuery(owner, Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlbumPicturesStillProcessing_FallsBackToTheOriginal() - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id, pictures: EntityPictures.Pending("original.png")); - await SeedAsync(owner, album); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, PictureSize.Small), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_AlbumPicturesStillProcessing_FallsBackToTheOriginal() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id, pictures: EntityPictures.Pending("original.png")); + await SeedAsync(owner, album); - Assert.False(result.IsError); - Assert.Contains($"{owner.Id}", result.Value.Key); - Assert.EndsWith("original.png", result.Value.Key); - Assert.Equal("image/png", result.Value.ContentType); - } + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, PictureSize.Small), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_RemovingAlbum_ReturnsNotFound() - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - album.LifeCycleStatus = LifeCycleStatus.Removing; - await SeedAsync(owner, album); + Assert.False(result.IsError); + Assert.Contains($"{owner.Id}", result.Value.Key); + Assert.EndsWith("original.png", result.Value.Key); + Assert.Equal("image/png", result.Value.ContentType); + } - var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_RemovingAlbum_ReturnsNotFound() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + album.LifeCycleStatus = LifeCycleStatus.Removing; + await SeedAsync(owner, album); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs index f37c54e..81e228d 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs @@ -5,125 +5,126 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class AddTracksToPlayListCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private AddTracksToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_FirstTrack_AddsItAtPositionZero() + public sealed class AddTracksToPlayListCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.Track(owner); - await SeedAsync(owner, playList, track); + private AddTracksToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); - var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_FirstTrack_AddsItAtPositionZero() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var track = TestEntities.Track(owner); + await SeedAsync(owner, playList, track); - Assert.False(result.IsError); - var link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Equal(0, link.Position); - } + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayListAlreadyHasTracks_AppendsAtNextPosition() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var existingTrack = TestEntities.Track(owner, "Existing"); - var newTrack = TestEntities.Track(owner, "New"); - await SeedAsync( - owner, playList, existingTrack, newTrack, - new PlayListHasTrack { PlayListId = playList.Id, TrackId = existingTrack.Id, Position = 0 }); + Assert.False(result.IsError); + var link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal(0, link.Position); + } - var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [newTrack.Id]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PlayListAlreadyHasTracks_AppendsAtNextPosition() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var existingTrack = TestEntities.Track(owner, "Existing"); + var newTrack = TestEntities.Track(owner, "New"); + await SeedAsync( + owner, playList, existingTrack, newTrack, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = existingTrack.Id, Position = 0 }); - Assert.False(result.IsError); - var newLink = await Database.PlayListHasTracks.SingleAsync(link => link.TrackId == newTrack.Id, TestContext.Current.CancellationToken); - Assert.Equal(1, newLink.Position); - } + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [newTrack.Id]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(1, Guid.NewGuid(), [Guid.NewGuid()]), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + var newLink = await Database.PlayListHasTracks.SingleAsync(link => link.TrackId == newTrack.Id, TestContext.Current.CancellationToken); + Assert.Equal(1, newLink.Position); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(1, Guid.NewGuid(), [Guid.NewGuid()]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NotOwner_ReturnsForbidden() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, stranger, playList); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(stranger.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NotOwner_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, stranger, playList); - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(stranger.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackAlreadyInPlayList_IsSkipped() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.Track(owner); - await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackAlreadyInPlayList_IsSkipped() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var track = TestEntities.Track(owner); + await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); - Assert.False(result.IsError); - Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken); - [Fact] - public async Task TrySaveChangesAsync_DuplicatePlayListTrack_MapsTheUniqueViolationToTheGivenError() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.Track(owner); - await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); - Database.PlayListHasTracks.Add(new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 1 }); + Assert.False(result.IsError); + Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await Database.TrySaveChangesAsync(Error.Conflict("Test.Duplicate", "duplicate"), TestContext.Current.CancellationToken); + [Fact] + public async Task TrySaveChangesAsync_DuplicatePlayListTrack_MapsTheUniqueViolationToTheGivenError() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var track = TestEntities.Track(owner); + await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); + Database.PlayListHasTracks.Add(new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 1 }); - Assert.Equal("Test.Duplicate", result.FirstError.Code); - } + var result = await Database.TrySaveChangesAsync(Error.Conflict("Test.Duplicate", "duplicate"), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_SeveralTracks_AppendsThemInOrderSkippingExistingOnes() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var existing = TestEntities.Track(owner, "Existing"); - var first = TestEntities.Track(owner, "First"); - var second = TestEntities.Track(owner, "Second"); - await SeedAsync( - owner, playList, existing, first, second, - new PlayListHasTrack { PlayListId = playList.Id, TrackId = existing.Id, Position = 0 }); + Assert.Equal("Test.Duplicate", result.FirstError.Code); + } - var result = await CreateHandler().Handle( - new AddTracksToPlayListCommand(owner.Id, playList.Id, [first.Id, existing.Id, second.Id, first.Id]), - TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_SeveralTracks_AppendsThemInOrderSkippingExistingOnes() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var existing = TestEntities.Track(owner, "Existing"); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + await SeedAsync( + owner, playList, existing, first, second, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = existing.Id, Position = 0 }); - Assert.False(result.IsError); - var links = await Database.PlayListHasTracks.OrderBy(link => link.Position).ToListAsync(TestContext.Current.CancellationToken); - Assert.Equal([existing.Id, first.Id, second.Id], links.Select(link => link.TrackId)); - Assert.Equal([0, 1, 2], links.Select(link => link.Position)); + var result = await CreateHandler().Handle( + new AddTracksToPlayListCommand(owner.Id, playList.Id, [first.Id, existing.Id, second.Id, first.Id]), + TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var links = await Database.PlayListHasTracks.OrderBy(link => link.Position).ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal([existing.Id, first.Id, second.Id], links.Select(link => link.TrackId)); + Assert.Equal([0, 1, 2], links.Select(link => link.Position)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs index 53ffc79..986c877 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs @@ -6,106 +6,107 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); - - private CreatePlayListCommandHandler CreateHandler() => new( - eventBus, - Database, - new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), - NoOpLogger(), - TestConfigurations.PlayList()); - - [Fact] - public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures() + public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase { - var user = TestEntities.User(); - await SeedAsync(user); + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); - var command = new CreatePlayListCommand(user.Id, "My Playlist", "A description", PictureIntentId: null); + private CreatePlayListCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), + NoOpLogger(), + TestConfigurations.PlayList()); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures() + { + var user = TestEntities.User(); + await SeedAsync(user); - Assert.False(result.IsError); - Assert.Equal("My Playlist", result.Value.Name); - Assert.Null(result.Value.SmallImageKeyName); - await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); - } + var command = new CreatePlayListCommand(user.Id, "My Playlist", "A description", PictureIntentId: null); - [Fact] - public async Task Handle_WithValidPictureIntent_PublishesResourcesEvent() - { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var user = TestEntities.User(); - var intent = TestEntities.UploadIntent(user.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "cover.webp"); - await SeedAsync(user, intent); + Assert.False(result.IsError); + Assert.Equal("My Playlist", result.Value.Name); + Assert.Null(result.Value.SmallImageKeyName); + await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } - var command = new CreatePlayListCommand(user.Id, "My Playlist", null, intent.Id); + [Fact] + public async Task Handle_WithValidPictureIntent_PublishesResourcesEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var user = TestEntities.User(); + var intent = TestEntities.UploadIntent(user.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "cover.webp"); + await SeedAsync(user, intent); - Assert.False(result.IsError); - var stored = await Database.PlayLists.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("cover.webp", stored.Pictures?.OriginalName); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); - } + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, intent.Id); - [Fact] - public async Task Handle_NoVisibilitySpecified_DefaultsToPrivate() - { - var user = TestEntities.User(); - await SeedAsync(user); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null); + Assert.False(result.IsError); + var stored = await Database.PlayLists.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("cover.webp", stored.Pictures?.OriginalName); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NoVisibilitySpecified_DefaultsToPrivate() + { + var user = TestEntities.User(); + await SeedAsync(user); - Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Private, result.Value.Visibility); - } + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null); - [Fact] - public async Task Handle_PublicVisibility_PersistsIt() - { - var user = TestEntities.User(); - await SeedAsync(user); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null, Visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); + Assert.False(result.IsError); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Private, result.Value.Visibility); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PublicVisibility_PersistsIt() + { + var user = TestEntities.User(); + await SeedAsync(user); - Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); - } + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null, Visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var command = new CreatePlayListCommand(404, "Orphan Playlist", null, null); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new CreatePlayListCommand(404, "Orphan Playlist", null, null); - [Fact] - public async Task Handle_PictureIntentNotFound_ReturnsError() - { - var user = TestEntities.User(); - await SeedAsync(user); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new CreatePlayListCommand(user.Id, "My Playlist", null, Guid.NewGuid()); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PictureIntentNotFound_ReturnsError() + { + var user = TestEntities.User(); + await SeedAsync(user); - Assert.True(result.IsError); + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, Guid.NewGuid()); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs index d8559c7..b451aa6 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs @@ -6,48 +6,49 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private readonly IEventBus eventBus = Substitute.For(); - - private DeletePlayListCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); - - [Fact] - public async Task Handle_Owner_MarksPlayListAsRemoving() + public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); + private readonly IEventBus eventBus = Substitute.For(); - var result = await CreateHandler().Handle(new DeletePlayListCommand(owner.Id, playList.Id), TestContext.Current.CancellationToken); + private DeletePlayListCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); - Assert.False(result.IsError); - var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); - } + [Fact] + public async Task Handle_Owner_MarksPlayListAsRemoving() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new DeletePlayListCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new DeletePlayListCommand(owner.Id, playList.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + Assert.False(result.IsError); + var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } - [Fact] - public async Task Handle_NotOwner_ReturnsForbidden() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, stranger, playList); + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new DeletePlayListCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + [Fact] + public async Task Handle_NotOwner_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, stranger, playList); + + var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs index 1d7a5b7..ef95972 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs @@ -4,81 +4,82 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private GetPlayListByIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds() + public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); - var track = TestEntities.Track(owner); - await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); + private GetPlayListByIdQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); + var track = TestEntities.Track(owner); + await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); - Assert.False(result.IsError); - Assert.Equal(playList.Name, result.Value.Name); - Assert.Equal([track.Id], result.Value.CoverTrackIds); - } + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(playList.Name, result.Value.Name); + Assert.Equal([track.Id], result.Value.CoverTrackIds); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PrivatePlayList_RequestedByOwner_ReturnsIt() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private); - await SeedAsync(owner, playList); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PrivatePlayList_RequestedByOwner_ReturnsIt() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private); + await SeedAsync(owner, playList); - Assert.False(result.IsError); - } + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, owner.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PrivatePlayList_RequestedByStranger_ReturnsNotFound() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private); - await SeedAsync(owner, stranger, playList); + Assert.False(result.IsError); + } - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, stranger.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PrivatePlayList_RequestedByStranger_ReturnsNotFound() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private); + await SeedAsync(owner, stranger, playList); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, stranger.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PrivatePlayList_RequestedAnonymously_ReturnsNotFound() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private); - await SeedAsync(owner, playList); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PrivatePlayList_RequestedAnonymously_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private); + await SeedAsync(owner, playList); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PublicPlayList_RequestedAnonymously_ReturnsIt() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); - await SeedAsync(owner, playList); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PublicPlayList_RequestedAnonymously_ReturnsIt() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); + await SeedAsync(owner, playList); - Assert.False(result.IsError); + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs index f97d132..0589d68 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs @@ -5,59 +5,60 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private GetPlayListTracksQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_TracksOrderedByPosition_ReturnsThemInOrder() + public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var second = TestEntities.Track(owner, "Second"); - var first = TestEntities.Track(owner, "First"); - await SeedAsync( - owner, playList, second, first, - new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 }, - new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 }); + private GetPlayListTracksQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TracksOrderedByPosition_ReturnsThemInOrder() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var second = TestEntities.Track(owner, "Second"); + var first = TestEntities.Track(owner, "First"); + await SeedAsync( + owner, playList, second, first, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 }, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 }); - Assert.False(result.IsError); - Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); - } + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetPlayListTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PrivatePlayListOfAnotherUser_ReturnsNotFound() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private); - await SeedAsync(owner, playList); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PrivatePlayListOfAnotherUser_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private); + await SeedAsync(owner, playList); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PrivatePlayListOfViewer_ReturnsTracks() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private); - await SeedAsync(owner, playList); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PrivatePlayListOfViewer_ReturnsTracks() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private); + await SeedAsync(owner, playList); - Assert.False(result.IsError); + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs index 76b0a75..4e1d4b9 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs @@ -3,63 +3,64 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private GetPlayListsByUserIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ReturnsOnlyActivePlayListsForThatUser() + public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(1, "owner"); - var other = TestEntities.User(2, "other"); - await SeedAsync( - owner, other, - TestEntities.PlayList(owner.Id, "Mine"), - TestEntities.PlayList(owner.Id, "Being removed", lifeCycleStatus: LifeCycleStatus.Removing), - TestEntities.PlayList(other.Id, "Theirs")); + private GetPlayListsByUserIdQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); - var playList = Assert.Single(result.Items); - Assert.Equal("Mine", playList.Name); - } + [Fact] + public async Task Handle_ReturnsOnlyActivePlayListsForThatUser() + { + var owner = TestEntities.User(1, "owner"); + var other = TestEntities.User(2, "other"); + await SeedAsync( + owner, other, + TestEntities.PlayList(owner.Id, "Mine"), + TestEntities.PlayList(owner.Id, "Being removed", lifeCycleStatus: LifeCycleStatus.Removing), + TestEntities.PlayList(other.Id, "Theirs")); - [Fact] - public async Task Handle_AnotherViewer_ReturnsOnlyPublicPlayLists() - { - var owner = TestEntities.User(); - await SeedAsync( - owner, - TestEntities.PlayList(owner.Id, "Public one", visibility: PlayListVisibility.Public), - TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private)); + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + var playList = Assert.Single(result.Items); + Assert.Equal("Mine", playList.Name); + } - var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); - var playList = Assert.Single(result.Items); - Assert.Equal("Public one", playList.Name); - } + [Fact] + public async Task Handle_AnotherViewer_ReturnsOnlyPublicPlayLists() + { + var owner = TestEntities.User(); + await SeedAsync( + owner, + TestEntities.PlayList(owner.Id, "Public one", visibility: PlayListVisibility.Public), + TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private)); - [Fact] - public async Task Handle_Owner_SeesPrivatePlayLists() - { - var owner = TestEntities.User(); - await SeedAsync( - owner, - TestEntities.PlayList(owner.Id, "Public one", visibility: PlayListVisibility.Public), - TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private)); + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); + var playList = Assert.Single(result.Items); + Assert.Equal("Public one", playList.Name); + } - var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); - Assert.Equal(2, result.Items.Count); - } + [Fact] + public async Task Handle_Owner_SeesPrivatePlayLists() + { + var owner = TestEntities.User(); + await SeedAsync( + owner, + TestEntities.PlayList(owner.Id, "Public one", visibility: PlayListVisibility.Public), + TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private)); - [Fact] - public async Task Handle_NameFilter_ReturnsOnlyMatchingPlayLists() - { - var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.PlayList(owner.Id, "Road Trip"), TestEntities.PlayList(owner.Id, "Study Focus")); + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + Assert.Equal(2, result.Items.Count); + } - var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); - var playList = Assert.Single(result.Items); - Assert.Equal("Road Trip", playList.Name); + [Fact] + public async Task Handle_NameFilter_ReturnsOnlyMatchingPlayLists() + { + var owner = TestEntities.User(); + await SeedAsync(owner, TestEntities.PlayList(owner.Id, "Road Trip"), TestEntities.PlayList(owner.Id, "Study Focus")); + + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + var playList = Assert.Single(result.Items); + Assert.Equal("Road Trip", playList.Name); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs index c996a45..e73a127 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs @@ -4,78 +4,79 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private RemoveTrackFromPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_TrackInPlayList_RemovesTheLink() + public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.Track(owner); - await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); + private RemoveTrackFromPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); - var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackInPlayList_RemovesTheLink() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var track = TestEntities.Track(owner); + await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); - Assert.False(result.IsError); - Assert.Empty(Database.PlayListHasTracks); - } + var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Empty(Database.PlayListHasTracks); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NotOwner_ReturnsForbidden() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, stranger, playList); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NotOwner_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, stranger, playList); - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackNotLinked_ReturnsNotFound() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackNotLinked_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackInTheMiddle_RenumbersTheRemainingTracks() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var first = TestEntities.Track(owner, "First"); - var second = TestEntities.Track(owner, "Second"); - var third = TestEntities.Track(owner, "Third"); - await SeedAsync( - owner, playList, first, second, third, - new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 }, - new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 }, - new PlayListHasTrack { PlayListId = playList.Id, TrackId = third.Id, Position = 2 }); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, second.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackInTheMiddle_RenumbersTheRemainingTracks() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + var third = TestEntities.Track(owner, "Third"); + await SeedAsync( + owner, playList, first, second, third, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 }, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 }, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = third.Id, Position = 2 }); - Assert.False(result.IsError); - var remaining = Database.PlayListHasTracks.OrderBy(link => link.Position).ToList(); - Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId)); - Assert.Equal([0, 1], remaining.Select(link => link.Position)); + var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, second.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var remaining = Database.PlayListHasTracks.OrderBy(link => link.Position).ToList(); + Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId)); + Assert.Equal([0, 1], remaining.Select(link => link.Position)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs index 928762e..1b967bc 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs @@ -8,70 +8,71 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private readonly IStorageService storageService = Substitute.For(); - - private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTestBase { - storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns("https://storage.musify.test/presigned-upload"); + private readonly IStorageService storageService = Substitute.For(); - var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); + private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + { + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); - return new RequestPlayListPictureUploadCommandHandler( - new UploadIntentIssuer( - Database, - storageService, - new UploadIntentValidator(Database, storageService, config), - TestConfigurations.Storage(), - config, - NoOpLogger()), - TestConfigurations.PlayList()); - } + var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); - [Fact] - public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent() - { - var user = TestEntities.User(); - await SeedAsync(user); + return new RequestPlayListPictureUploadCommandHandler( + new UploadIntentIssuer( + Database, + storageService, + new UploadIntentValidator(Database, storageService, config), + TestConfigurations.Storage(), + config, + NoOpLogger()), + TestConfigurations.PlayList()); + } - var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp"); + [Fact] + public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent() + { + var user = TestEntities.User(); + await SeedAsync(user); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp"); - Assert.False(result.IsError); - Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl); - Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var command = new RequestPlayListPictureUploadCommand(404, "webp", "image/webp"); + Assert.False(result.IsError); + Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl); + Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new RequestPlayListPictureUploadCommand(404, "webp", "image/webp"); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent() - { - var config = TestConfigurations.UploadIntent(); - config.MaxActiveUploadBytesPerUser = 100; + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var user = TestEntities.User(); - await SeedAsync(user); + [Fact] + public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent() + { + var config = TestConfigurations.UploadIntent(); + config.MaxActiveUploadBytesPerUser = 100; - var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000); + var user = TestEntities.User(); + await SeedAsync(user); - var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken); + var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000); - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs index c151868..f7951bf 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs @@ -6,128 +6,129 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); - - private UpdatePlayListCommandHandler CreateHandler() => new( - eventBus, - Database, - new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), - NoOpLogger(), - TestConfigurations.PlayList()); - - [Fact] - public async Task Handle_Owner_RenamesPlayList() + public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, "Old Name"); - await SeedAsync(owner, playList); + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); - var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); + private UpdatePlayListCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), + NoOpLogger(), + TestConfigurations.PlayList()); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_Owner_RenamesPlayList() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, "Old Name"); + await SeedAsync(owner, playList); - Assert.False(result.IsError); - Assert.Equal("New Name", result.Value.Name); - } + var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var command = new UpdatePlayListCommand(1, Guid.NewGuid(), "New Name", null, null); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal("New Name", result.Value.Name); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var command = new UpdatePlayListCommand(1, Guid.NewGuid(), "New Name", null, null); - [Fact] - public async Task Handle_NotOwner_ReturnsForbidden() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, stranger, playList); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, null); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NotOwner_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, stranger, playList); - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + var command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, null); - [Fact] - public async Task Handle_NewVisibility_UpdatesIt() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlayListVisibility.Public); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NewVisibility_UpdatesIt() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); - Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); - } + var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlayListVisibility.Public); - [Fact] - public async Task Handle_NoVisibilitySpecified_LeavesItUnchanged() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); - await SeedAsync(owner, playList); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); + Assert.False(result.IsError); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NoVisibilitySpecified_LeavesItUnchanged() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); + await SeedAsync(owner, playList); - Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); - } + var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); - [Fact] - public async Task Handle_BlankName_LeavesItUnchangedAndBlankDescriptionClearsIt() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description"); - await SeedAsync(owner, playList); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new UpdatePlayListCommand(owner.Id, playList.Id, " ", " ", null); + Assert.False(result.IsError); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_BlankName_LeavesItUnchangedAndBlankDescriptionClearsIt() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description"); + await SeedAsync(owner, playList); - Assert.False(result.IsError); - Assert.Equal("Original Name", result.Value.Name); - Assert.Null(result.Value.Description); - } + var command = new UpdatePlayListCommand(owner.Id, playList.Id, " ", " ", null); - [Fact] - public async Task Handle_PlayListWithoutPictures_ValidPictureIntent_SetsPictureAndPublishesSourceEvent() - { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - playList.Pictures = null; - var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "new-cover.webp"); - await SeedAsync(owner, playList, intent); + Assert.False(result.IsError); + Assert.Equal("Original Name", result.Value.Name); + Assert.Null(result.Value.Description); + } - var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, intent.Id); + [Fact] + public async Task Handle_PlayListWithoutPictures_ValidPictureIntent_SetsPictureAndPublishesSourceEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + playList.Pictures = null; + var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "new-cover.webp"); + await SeedAsync(owner, playList, intent); - Assert.False(result.IsError); - Assert.Null(result.Value.SmallImageKeyName); - var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, intent.Id); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Null(result.Value.SmallImageKeyName); + var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs index 21b4d47..eaaf27b 100644 --- a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs @@ -5,59 +5,60 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Services; - -public sealed class TrackStreamIssuerTests +namespace Musify.Application.Tests.Services { - private readonly IStreamTicketService ticketService = Substitute.For(); - - public TrackStreamIssuerTests() => - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("signed-token", 120)); - - private TrackStreamIssuer CreateIssuer(PlaybackConfiguration? playback = null) => new( - ticketService, - TestConfigurations.Track(), - TestConfigurations.StreamGateway("https://stream.musify.test/"), - playback ?? TestConfigurations.Playback()); - - [Fact] - public void Issue_BuildsManifestUrlAndCarriesTheListenId() + public sealed class TrackStreamIssuerTests { - var listenId = Guid.NewGuid(); + private readonly IStreamTicketService ticketService = Substitute.For(); - var response = CreateIssuer().Issue("folder-42", userId: 7, listenId); + public TrackStreamIssuerTests() => + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("signed-token", 120)); - Assert.Equal("signed-token", response.Ticket); - Assert.Equal(120, response.ExpiresInSeconds); - Assert.Equal(listenId, response.ListenId); - Assert.Equal("https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", response.ManifestUrl); - } + private TrackStreamIssuer CreateIssuer(PlaybackConfiguration? playback = null) => new( + ticketService, + TestConfigurations.Track(), + TestConfigurations.StreamGateway("https://stream.musify.test/"), + playback ?? TestConfigurations.Playback()); - [Fact] - public void Issue_PassesTheProcessedAudioKeyPrefixToTheTicketService() - { - CreateIssuer().Issue("folder-42", userId: 1, listenId: null); + [Fact] + public void Issue_BuildsManifestUrlAndCarriesTheListenId() + { + var listenId = Guid.NewGuid(); - ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); - } + var response = CreateIssuer().Issue("folder-42", userId: 7, listenId); - [Fact] - public void Issue_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService() - { - var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); + Assert.Equal("signed-token", response.Ticket); + Assert.Equal(120, response.ExpiresInSeconds); + Assert.Equal(listenId, response.ListenId); + Assert.Equal("https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", response.ManifestUrl); + } - CreateIssuer(playback).Issue("folder-42", userId: null, listenId: null); + [Fact] + public void Issue_PassesTheProcessedAudioKeyPrefixToTheTicketService() + { + CreateIssuer().Issue("folder-42", userId: 1, listenId: null); - ticketService.Received(1).IssueTicket(null, "Tracks/ProcessedAudios/folder-42/", 30L * playback.EstimatedAudioBytesPerSecond); - } + ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); + } - [Fact] - public void Issue_AuthenticatedUser_IgnoresTheAnonymousFragmentCap() - { - var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); + [Fact] + public void Issue_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService() + { + var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); - CreateIssuer(playback).Issue("folder-42", userId: 1, listenId: null); + CreateIssuer(playback).Issue("folder-42", userId: null, listenId: null); - ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); + ticketService.Received(1).IssueTicket(null, "Tracks/ProcessedAudios/folder-42/", 30L * playback.EstimatedAudioBytesPerSecond); + } + + [Fact] + public void Issue_AuthenticatedUser_IgnoresTheAnonymousFragmentCap() + { + var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); + + CreateIssuer(playback).Issue("folder-42", userId: 1, listenId: null); + + ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs index 95967e3..fc439b9 100644 --- a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs @@ -7,152 +7,153 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Services; - -public sealed class UploadIntentValidatorTests : HandlerTestBase +namespace Musify.Application.Tests.Services { - private readonly IStorageService storageService = Substitute.For(); - private readonly Domain.Entities.User owner = TestEntities.User(); - - private UploadIntentValidator CreateValidator(UploadIntentConfiguration? config = null) => - new(Database, storageService, config ?? TestConfigurations.UploadIntent()); - - [Fact] - public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent() + public sealed class UploadIntentValidatorTests : HandlerTestBase { - storageService - .HeadObjectAsync("bucket", "temp/object.webp", Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 2048)); + private readonly IStorageService storageService = Substitute.For(); + private readonly Domain.Entities.User owner = TestEntities.User(); - var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp"); - await SeedAsync(owner, intent); + private UploadIntentValidator CreateValidator(UploadIntentConfiguration? config = null) => + new(Database, storageService, config ?? TestConfigurations.UploadIntent()); - var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + [Fact] + public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent() + { + storageService + .HeadObjectAsync("bucket", "temp/object.webp", Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 2048)); - Assert.False(result.IsError); - Assert.Equal(intent.Id, result.Value.Id); - } + var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp"); + await SeedAsync(owner, intent); - [Fact] - public async Task ValidateAndLoadAsync_IntentIssuedForAnotherPurpose_ReturnsValidationError() - { - var intent = TestEntities.UploadIntent(owner.Id, UploadIntentPurpose.TrackAudio); - await SeedAsync(owner, intent); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); - var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(intent.Id, result.Value.Id); + } - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + [Fact] + public async Task ValidateAndLoadAsync_IntentIssuedForAnotherPurpose_ReturnsValidationError() + { + var intent = TestEntities.UploadIntent(owner.Id, UploadIntentPurpose.TrackAudio); + await SeedAsync(owner, intent); - [Fact] - public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound() - { - var result = await CreateValidator().ValidateAndLoadAsync(Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } - [Fact] - public async Task ValidateAndLoadAsync_IntentBelongsToAnotherUser_ReturnsNotFound() - { - var stranger = TestEntities.User(2, "stranger"); - var intent = TestEntities.UploadIntent(owner.Id); - await SeedAsync(owner, stranger, intent); + [Fact] + public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound() + { + var result = await CreateValidator().ValidateAndLoadAsync(Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); - var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task ValidateAndLoadAsync_IntentBelongsToAnotherUser_ReturnsNotFound() + { + var stranger = TestEntities.User(2, "stranger"); + var intent = TestEntities.UploadIntent(owner.Id); + await SeedAsync(owner, stranger, intent); - [Fact] - public async Task ValidateAndLoadAsync_AlreadyConsumed_ReturnsConflict() - { - var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed); - await SeedAsync(owner, intent); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); - var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); - } + [Fact] + public async Task ValidateAndLoadAsync_AlreadyConsumed_ReturnsConflict() + { + var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed); + await SeedAsync(owner, intent); - [Fact] - public async Task ValidateAndLoadAsync_PastExpiry_ReturnsValidationError() - { - var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1)); - await SeedAsync(owner, intent); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); - var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + } - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + [Fact] + public async Task ValidateAndLoadAsync_PastExpiry_ReturnsValidationError() + { + var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1)); + await SeedAsync(owner, intent); - [Fact] - public async Task ValidateAndLoadAsync_ObjectNeverUploaded_ReturnsNotFound() - { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((ObjectMetaData?)null); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); - var intent = TestEntities.UploadIntent(owner.Id); - await SeedAsync(owner, intent); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } - var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + [Fact] + public async Task ValidateAndLoadAsync_ObjectNeverUploaded_ReturnsNotFound() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ObjectMetaData?)null); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var intent = TestEntities.UploadIntent(owner.Id); + await SeedAsync(owner, intent); - [Fact] - public async Task ValidateAndLoadAsync_UploadedObjectExceedsMaxSize_ReturnsValidationError() - { - var config = TestConfigurations.UploadIntent(); - config.MaxUploadBytes = 1024; + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 2048)); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var intent = TestEntities.UploadIntent(owner.Id); - await SeedAsync(owner, intent); + [Fact] + public async Task ValidateAndLoadAsync_UploadedObjectExceedsMaxSize_ReturnsValidationError() + { + var config = TestConfigurations.UploadIntent(); + config.MaxUploadBytes = 1024; - var result = await CreateValidator(config).ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 2048)); - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + var intent = TestEntities.UploadIntent(owner.Id); + await SeedAsync(owner, intent); - [Fact] - public async Task CheckQuotaAsync_WithinLimits_ReturnsSuccess() - { - await SeedAsync(owner); + var result = await CreateValidator(config).ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); - var result = await CreateValidator().CheckQuotaAsync(owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } - Assert.False(result.IsError); - } + [Fact] + public async Task CheckQuotaAsync_WithinLimits_ReturnsSuccess() + { + await SeedAsync(owner); - [Fact] - public async Task CheckQuotaAsync_TooManyActiveIntents_ReturnsValidationError() - { - var config = TestConfigurations.UploadIntent(); - config.MaxActiveUploadIntentsPerUser = 1; - await SeedAsync(owner, TestEntities.UploadIntent(owner.Id)); + var result = await CreateValidator().CheckQuotaAsync(owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken); - var result = await CreateValidator(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); + Assert.False(result.IsError); + } - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + [Fact] + public async Task CheckQuotaAsync_TooManyActiveIntents_ReturnsValidationError() + { + var config = TestConfigurations.UploadIntent(); + config.MaxActiveUploadIntentsPerUser = 1; + await SeedAsync(owner, TestEntities.UploadIntent(owner.Id)); - [Fact] - public async Task CheckQuotaAsync_ExpiredOrConsumedIntentsDoNotCountTowardsTheLimit() - { - var config = TestConfigurations.UploadIntent(); - config.MaxActiveUploadIntentsPerUser = 1; - await SeedAsync( - owner, - TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired), - TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed)); + var result = await CreateValidator(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); - var result = await CreateValidator(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } - Assert.False(result.IsError); + [Fact] + public async Task CheckQuotaAsync_ExpiredOrConsumedIntentsDoNotCountTowardsTheLimit() + { + var config = TestConfigurations.UploadIntent(); + config.MaxActiveUploadIntentsPerUser = 1; + await SeedAsync( + owner, + TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired), + TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed)); + + var result = await CreateValidator(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs b/backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs index 8311815..447256b 100644 --- a/backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs +++ b/backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs @@ -1,40 +1,41 @@ using Musify.Application.Shared; using Xunit; -namespace Musify.Application.Tests.Shared; - -public sealed class StorageKeyTests +namespace Musify.Application.Tests.Shared { - [Fact] - public void Combine_MultipleSegments_JoinsThemWithForwardSlashes() + public sealed class StorageKeyTests { - var key = StorageKey.Combine("Tracks", "SmallPictures", "cover.webp"); + [Fact] + public void Combine_MultipleSegments_JoinsThemWithForwardSlashes() + { + var key = StorageKey.Combine("Tracks", "SmallPictures", "cover.webp"); - Assert.Equal("Tracks/SmallPictures/cover.webp", key); - } + Assert.Equal("Tracks/SmallPictures/cover.webp", key); + } - [Fact] - public void Combine_SegmentsWithSurroundingSlashesAndWhitespace_TrimsThem() - { - var key = StorageKey.Combine(" Tracks/ ", "/SmallPictures/", "cover.webp"); + [Fact] + public void Combine_SegmentsWithSurroundingSlashesAndWhitespace_TrimsThem() + { + var key = StorageKey.Combine(" Tracks/ ", "/SmallPictures/", "cover.webp"); - Assert.Equal("Tracks/SmallPictures/cover.webp", key); - } + Assert.Equal("Tracks/SmallPictures/cover.webp", key); + } - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void Combine_BlankSegment_IsSkipped(string? blank) - { - var key = StorageKey.Combine("Tracks", blank!, "cover.webp"); + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Combine_BlankSegment_IsSkipped(string? blank) + { + var key = StorageKey.Combine("Tracks", blank!, "cover.webp"); - Assert.Equal("Tracks/cover.webp", key); - } + Assert.Equal("Tracks/cover.webp", key); + } - [Fact] - public void Combine_NoSegments_ReturnsEmptyString() - { - Assert.Equal(string.Empty, StorageKey.Combine()); + [Fact] + public void Combine_NoSegments_ReturnsEmptyString() + { + Assert.Equal(string.Empty, StorageKey.Combine()); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs index 4ad7a9e..dae34fd 100644 --- a/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs +++ b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs @@ -2,31 +2,32 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks.Responses; using Xunit; -namespace Musify.Application.Tests.Shared; - -public sealed class TrackApplicationResponseTests +namespace Musify.Application.Tests.Shared { - [Fact] - public void FromEntity_UsesOwnerNameAsArtist() + public sealed class TrackApplicationResponseTests { - var owner = TestEntities.User(1, "The Owner"); - var track = TestEntities.Track(owner); + [Fact] + public void FromEntity_UsesOwnerNameAsArtist() + { + var owner = TestEntities.User(1, "The Owner"); + var track = TestEntities.Track(owner); - var response = TrackApplicationResponse.FromEntity(track, listensCount: 3); + var response = TrackApplicationResponse.FromEntity(track, listensCount: 3); - Assert.Equal("The Owner", response.Artist); - Assert.Equal(owner.Id, response.OwnerUserId); - Assert.Equal(3, response.ListensCount); - Assert.False(response.IsExplicit); - } + Assert.Equal("The Owner", response.Artist); + Assert.Equal(owner.Id, response.OwnerUserId); + Assert.Equal(3, response.ListensCount); + Assert.False(response.IsExplicit); + } - [Fact] - public void FromEntity_CopiesExplicitFlag() - { - var track = TestEntities.Track(TestEntities.User(), isExplicit: true); + [Fact] + public void FromEntity_CopiesExplicitFlag() + { + var track = TestEntities.Track(TestEntities.User(), isExplicit: true); - var response = TrackApplicationResponse.FromEntity(track, listensCount: 0); + var response = TrackApplicationResponse.FromEntity(track, listensCount: 0); - Assert.True(response.IsExplicit); + Assert.True(response.IsExplicit); + } } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs index 544efda..6926283 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs @@ -2,29 +2,30 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Xunit; -namespace Musify.Application.Tests.TestSupport; - -public abstract class HandlerTestBase : IAsyncLifetime +namespace Musify.Application.Tests.TestSupport { - private TestDatabase database = null!; - - protected TestDatabase Database => database; - - public async ValueTask InitializeAsync() => database = await TestDatabase.CreateAsync(); - - public ValueTask DisposeAsync() + public abstract class HandlerTestBase : IAsyncLifetime { - GC.SuppressFinalize(this); - return database.DisposeAsync(); - } + private TestDatabase database = null!; - protected static ILogger NoOpLogger() => NullLogger.Instance; + protected TestDatabase Database => database; - protected async Task SeedAsync(params object[] entities) - { - foreach (var entity in entities) - Database.Add(entity); + public async ValueTask InitializeAsync() => database = await TestDatabase.CreateAsync(); - await Database.SaveChangesAsync(CancellationToken.None); + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + return database.DisposeAsync(); + } + + protected static ILogger NoOpLogger() => NullLogger.Instance; + + protected async Task SeedAsync(params object[] entities) + { + foreach (var entity in entities) + Database.Add(entity); + + await Database.SaveChangesAsync(CancellationToken.None); + } } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs index 56bb542..2d45ebe 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs @@ -1,25 +1,26 @@ using Musify.Application.Configuration; -namespace Musify.Application.Tests.TestSupport; - -public static class TestConfigurations +namespace Musify.Application.Tests.TestSupport { - public static ApplicationStorageConfiguration Storage(string bucket = "test-bucket") => - new() { Bucket = bucket }; + public static class TestConfigurations + { + public static ApplicationStorageConfiguration Storage(string bucket = "test-bucket") => + new() { Bucket = bucket }; - public static TrackConfiguration Track() => new(); + public static TrackConfiguration Track() => new(); - public static PlayListConfiguration PlayList() => new(); + public static PlayListConfiguration PlayList() => new(); - public static AlbumConfiguration Album() => new(); + public static AlbumConfiguration Album() => new(); - public static MixConfiguration Mix() => new(); + public static MixConfiguration Mix() => new(); - public static UploadIntentConfiguration UploadIntent() => new(); + public static UploadIntentConfiguration UploadIntent() => new(); - public static StreamGatewayConfiguration StreamGateway(string publicBaseUrl = "https://stream.musify.test") => - new() { PublicBaseUrl = publicBaseUrl }; + public static StreamGatewayConfiguration StreamGateway(string publicBaseUrl = "https://stream.musify.test") => + new() { PublicBaseUrl = publicBaseUrl }; - public static PlaybackConfiguration Playback(bool allowAnonymousListening = false, int anonymousFragmentSeconds = 0) => - new() { AllowAnonymousListening = allowAnonymousListening, AnonymousFragmentSeconds = anonymousFragmentSeconds }; + public static PlaybackConfiguration Playback(bool allowAnonymousListening = false, int anonymousFragmentSeconds = 0) => + new() { AllowAnonymousListening = allowAnonymousListening, AnonymousFragmentSeconds = anonymousFragmentSeconds }; + } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs index b995a40..a08d919 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -5,96 +5,97 @@ using Musify.Application.Contracts; using Musify.Domain.Entities; using Musify.Infrastructure.Persistence; -namespace Musify.Application.Tests.TestSupport; - -public sealed class TestDatabase : DbContext, IDatabase +namespace Musify.Application.Tests.TestSupport { - private readonly SqliteConnection connection; - - private TestDatabase(SqliteConnection connection, DbContextOptions options) - : base(options) + public sealed class TestDatabase : DbContext, IDatabase { - this.connection = connection; - } + private readonly SqliteConnection connection; - public static async Task CreateAsync() - { - var connection = new SqliteConnection("Filename=:memory:"); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; - - var database = new TestDatabase(connection, options); - await database.Database.EnsureCreatedAsync(); - return database; - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - ModelConfiguration.Apply(modelBuilder); - } - - public DbSet Users => Set(); - - public DbSet PlayLists => Set(); - - public DbSet Albums => Set(); - - public DbSet AlbumHasTracks => Set(); - - public DbSet Tracks => Set(); - - public DbSet PlayListHasTracks => Set(); - - public DbSet Mixes => Set(); - - public DbSet MixItems => Set(); - - public DbSet UploadIntents => Set(); - - public DbSet ListeningHistories => Set(); - - public DbSet TrackLikes => Set(); - - public DbSet TrackTags => Set(); - - public DbSet UserFollows => Set(); - - public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) - { - var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); - return new SqliteDatabaseTransaction(transaction); - } - - public async Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken) - { - try + private TestDatabase(SqliteConnection connection, DbContextOptions options) + : base(options) { - await SaveChangesAsync(cancellationToken); - return Result.Success; + this.connection = connection; } - catch (DbUpdateException exception) when (exception.InnerException is SqliteException { SqliteErrorCode: 19 }) + + public static async Task CreateAsync() { - return onUniqueViolation; + var connection = new SqliteConnection("Filename=:memory:"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + var database = new TestDatabase(connection, options); + await database.Database.EnsureCreatedAsync(); + return database; } - } - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync(); - await connection.DisposeAsync(); - } + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); - private sealed class SqliteDatabaseTransaction(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction inner) - : IDatabaseTransaction - { - public Task CommitAsync(CancellationToken cancellationToken = default) => - inner.CommitAsync(cancellationToken); + ModelConfiguration.Apply(modelBuilder); + } - public ValueTask DisposeAsync() => inner.DisposeAsync(); + public DbSet Users => Set(); + + public DbSet PlayLists => Set(); + + public DbSet Albums => Set(); + + public DbSet AlbumHasTracks => Set(); + + public DbSet Tracks => Set(); + + public DbSet PlayListHasTracks => Set(); + + public DbSet Mixes => Set(); + + public DbSet MixItems => Set(); + + public DbSet UploadIntents => Set(); + + public DbSet ListeningHistories => Set(); + + public DbSet TrackLikes => Set(); + + public DbSet TrackTags => Set(); + + public DbSet UserFollows => Set(); + + public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) + { + var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); + return new SqliteDatabaseTransaction(transaction); + } + + public async Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken) + { + try + { + await SaveChangesAsync(cancellationToken); + return Result.Success; + } + catch (DbUpdateException exception) when (exception.InnerException is SqliteException { SqliteErrorCode: 19 }) + { + return onUniqueViolation; + } + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + await connection.DisposeAsync(); + } + + private sealed class SqliteDatabaseTransaction(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction inner) + : IDatabaseTransaction + { + public Task CommitAsync(CancellationToken cancellationToken = default) => + inner.CommitAsync(cancellationToken); + + public ValueTask DisposeAsync() => inner.DisposeAsync(); + } } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs index 3aeaca5..1d1d104 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -1,191 +1,192 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tests.TestSupport; - -public static class TestEntities +namespace Musify.Application.Tests.TestSupport { - public static User User( - long id = 1, - string name = "test-user", - string? firstName = "Test", - string? secondName = "User", - string? profilePictureUrl = null) => - new() + public static class TestEntities + { + public static User User( + long id = 1, + string name = "test-user", + string? firstName = "Test", + string? secondName = "User", + string? profilePictureUrl = null) => + new() + { + Id = id, + Name = name, + NormalizedName = name.ToUpperInvariant(), + FirstName = firstName, + SecondName = secondName, + ProfilePictureUrl = profilePictureUrl + }; + + public static TrackPictures ProcessedPictures() => new() { - Id = id, - Name = name, - NormalizedName = name.ToUpperInvariant(), - FirstName = firstName, - SecondName = secondName, - ProfilePictureUrl = profilePictureUrl + OriginalName = "original.webp", + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp", + ProcessingStatus = ProcessingStatus.Completed }; - public static TrackPictures ProcessedPictures() => new() - { - OriginalName = "original.webp", - SmallName = "small.webp", - MediumName = "medium.webp", - LargeName = "large.webp", - ProcessingStatus = ProcessingStatus.Completed - }; - - public static TrackPictures PendingPictures() => new() - { - OriginalName = "original.webp", - ProcessingStatus = ProcessingStatus.Pending - }; - - public static TrackAudio ProcessedAudio(string folderName = "audio-folder") => new() - { - OriginalName = "original.mp3", - FolderName = folderName, - TranscodeStatus = ProcessingStatus.Completed - }; - - public static TrackAudio PendingAudio() => new() - { - OriginalName = "original.mp3", - TranscodeStatus = ProcessingStatus.Pending - }; - - public static Track Track( - User owner, - string title = "Test Track", - double durationSeconds = 180, - TrackPictures? pictures = null, - TrackAudio? audio = null, - LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, - IEnumerable? tags = null, - bool isExplicit = false) - { - var track = new Track + public static TrackPictures PendingPictures() => new() { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - DurationSeconds = durationSeconds, - LifeCycleStatus = lifeCycleStatus, - IsExplicit = isExplicit, - OwnerUserId = owner.Id, - Owner = owner, - Pictures = pictures ?? ProcessedPictures(), - Audio = audio ?? ProcessedAudio() + OriginalName = "original.webp", + ProcessingStatus = ProcessingStatus.Pending }; - track.Tags = (tags ?? [Genre.Pop]) - .Select(tag => new TrackTag { TrackId = track.Id, Tag = tag }) - .ToList(); + public static TrackAudio ProcessedAudio(string folderName = "audio-folder") => new() + { + OriginalName = "original.mp3", + FolderName = folderName, + TranscodeStatus = ProcessingStatus.Completed + }; - return track; + public static TrackAudio PendingAudio() => new() + { + OriginalName = "original.mp3", + TranscodeStatus = ProcessingStatus.Pending + }; + + public static Track Track( + User owner, + string title = "Test Track", + double durationSeconds = 180, + TrackPictures? pictures = null, + TrackAudio? audio = null, + LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, + IEnumerable? tags = null, + bool isExplicit = false) + { + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = durationSeconds, + LifeCycleStatus = lifeCycleStatus, + IsExplicit = isExplicit, + OwnerUserId = owner.Id, + Owner = owner, + Pictures = pictures ?? ProcessedPictures(), + Audio = audio ?? ProcessedAudio() + }; + + track.Tags = (tags ?? [Genre.Pop]) + .Select(tag => new TrackTag { TrackId = track.Id, Tag = tag }) + .ToList(); + + return track; + } + + public static EntityPictures PlayListPictures() => new() + { + OriginalName = "original.webp", + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp" + }; + + public static PlayList PlayList( + long userId, + string name = "Test Playlist", + string? description = null, + EntityPictures? pictures = null, + LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, + PlayListVisibility visibility = PlayListVisibility.Private) => + new() + { + OwnerUserId = userId, + Name = name, + NormalizedName = name.ToUpperInvariant(), + Description = description, + Pictures = pictures ?? PlayListPictures(), + LifeCycleStatus = lifeCycleStatus, + Visibility = visibility + }; + + public static EntityPictures AlbumPictures() => new() + { + OriginalName = "original.webp", + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp" + }; + + public static Album Album( + long ownerUserId, + string title = "Test Album", + string? description = null, + int? releaseYear = 2024, + EntityPictures? pictures = null) => + new() + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + Description = description, + ReleaseYear = releaseYear, + OwnerUserId = ownerUserId, + Pictures = pictures ?? AlbumPictures() + }; + + public static UploadIntent UploadIntent( + long userId, + UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture, + string bucket = "test-bucket", + string key = "temp/object.webp", + string objectName = "object.webp", + string contentType = "image/webp", + long? expectedSizeBytes = 1024, + UploadIntentStatus status = UploadIntentStatus.Issued, + DateTime? expiresAt = null) => + new() + { + UserId = userId, + Bucket = bucket, + Key = key, + ObjectName = objectName, + ContentType = contentType, + ExpectedSizeBytes = expectedSizeBytes, + Purpose = purpose, + Status = status, + ExpiresAt = expiresAt ?? DateTime.UtcNow.AddMinutes(5) + }; + + public static Mix Mix( + long userId, + MixKind kind = MixKind.Discovery, + int position = 0) => + new() + { + UserId = userId, + Kind = kind, + Position = position + }; + + public static MixItem MixItem( + Guid mixId, + Guid trackId, + int position = 0) => + new() + { + MixId = mixId, + Position = position, + TrackId = trackId + }; + + public static ListeningHistory ListeningHistory( + long userId, + Guid trackId, + DateTime? listenedAt = null, + double? playedSeconds = null, + bool isCounted = true) => + new() + { + UserId = userId, + TrackId = trackId, + ListenedAt = listenedAt ?? DateTime.UtcNow, + PlayedSeconds = playedSeconds, + IsCounted = isCounted + }; } - - public static EntityPictures PlayListPictures() => new() - { - OriginalName = "original.webp", - SmallName = "small.webp", - MediumName = "medium.webp", - LargeName = "large.webp" - }; - - public static PlayList PlayList( - long userId, - string name = "Test Playlist", - string? description = null, - EntityPictures? pictures = null, - LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, - PlayListVisibility visibility = PlayListVisibility.Private) => - new() - { - OwnerUserId = userId, - Name = name, - NormalizedName = name.ToUpperInvariant(), - Description = description, - Pictures = pictures ?? PlayListPictures(), - LifeCycleStatus = lifeCycleStatus, - Visibility = visibility - }; - - public static EntityPictures AlbumPictures() => new() - { - OriginalName = "original.webp", - SmallName = "small.webp", - MediumName = "medium.webp", - LargeName = "large.webp" - }; - - public static Album Album( - long ownerUserId, - string title = "Test Album", - string? description = null, - int? releaseYear = 2024, - EntityPictures? pictures = null) => - new() - { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - Description = description, - ReleaseYear = releaseYear, - OwnerUserId = ownerUserId, - Pictures = pictures ?? AlbumPictures() - }; - - public static UploadIntent UploadIntent( - long userId, - UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture, - string bucket = "test-bucket", - string key = "temp/object.webp", - string objectName = "object.webp", - string contentType = "image/webp", - long? expectedSizeBytes = 1024, - UploadIntentStatus status = UploadIntentStatus.Issued, - DateTime? expiresAt = null) => - new() - { - UserId = userId, - Bucket = bucket, - Key = key, - ObjectName = objectName, - ContentType = contentType, - ExpectedSizeBytes = expectedSizeBytes, - Purpose = purpose, - Status = status, - ExpiresAt = expiresAt ?? DateTime.UtcNow.AddMinutes(5) - }; - - public static Mix Mix( - long userId, - MixKind kind = MixKind.Discovery, - int position = 0) => - new() - { - UserId = userId, - Kind = kind, - Position = position - }; - - public static MixItem MixItem( - Guid mixId, - Guid trackId, - int position = 0) => - new() - { - MixId = mixId, - Position = position, - TrackId = trackId - }; - - public static ListeningHistory ListeningHistory( - long userId, - Guid trackId, - DateTime? listenedAt = null, - double? playedSeconds = null, - bool isCounted = true) => - new() - { - UserId = userId, - TrackId = trackId, - ListenedAt = listenedAt ?? DateTime.UtcNow, - PlayedSeconds = playedSeconds, - IsCounted = isCounted - }; } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs index ecb346d..59a74c3 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -8,125 +8,126 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class CreateTrackCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); - - private CreateTrackCommandHandler CreateHandler() + public sealed class CreateTrackCommandHandlerTests : HandlerTestBase { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); - return new CreateTrackCommandHandler( - Database, - eventBus, - new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), - NoOpLogger(), - TestConfigurations.Track()); - } + private CreateTrackCommandHandler CreateHandler() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) => - (TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"), - TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3")); + return new CreateTrackCommandHandler( + Database, + eventBus, + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), + NoOpLogger(), + TestConfigurations.Track()); + } - [Fact] - public async Task Handle_SameIntentForPictureAndAudio_ReturnsValidationError() - { - var user = TestEntities.User(); - var (picture, audio) = SeedIntents(user.Id); - await SeedAsync(user, picture, audio); + private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) => + (TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"), + TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3")); - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, picture.Id, [Genre.Pop]); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_SameIntentForPictureAndAudio_ReturnsValidationError() + { + var user = TestEntities.User(); + var (picture, audio) = SeedIntents(user.Id); + await SeedAsync(user, picture, audio); - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, picture.Id, [Genre.Pop]); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent() - { - var user = TestEntities.User(); - var (picture, audio) = SeedIntents(user.Id); - await SeedAsync(user, picture, audio); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop, Genre.Rock]); + [Fact] + public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent() + { + var user = TestEntities.User(); + var (picture, audio) = SeedIntents(user.Id); + await SeedAsync(user, picture, audio); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop, Genre.Rock]); - Assert.False(result.IsError); - Assert.Equal("My Song", result.Value.Title); - var localResponse = Assert.IsType(result.Value); - Assert.Equal(user.Id, localResponse.OwnerUserId); - Assert.Equal([Genre.Pop, Genre.Rock], localResponse.Tags); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); - } + Assert.False(result.IsError); + Assert.Equal("My Song", result.Value.Title); + var localResponse = Assert.IsType(result.Value); + Assert.Equal(user.Id, localResponse.OwnerUserId); + Assert.Equal([Genre.Pop, Genre.Rock], localResponse.Tags); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task Handle_StoresExplicitFlag(bool isExplicit) - { - var user = TestEntities.User(); - var (picture, audio) = SeedIntents(user.Id); - await SeedAsync(user, picture, audio); + var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); + } - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop], isExplicit); + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Handle_StoresExplicitFlag(bool isExplicit) + { + var user = TestEntities.User(); + var (picture, audio) = SeedIntents(user.Id); + await SeedAsync(user, picture, audio); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop], isExplicit); - Assert.False(result.IsError); - Assert.Equal(isExplicit, result.Value.IsExplicit); - var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal(isExplicit, stored.IsExplicit); - } + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop]); + Assert.False(result.IsError); + Assert.Equal(isExplicit, result.Value.IsExplicit); + var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal(isExplicit, stored.IsExplicit); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop]); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PictureIntentBelongsToAnotherUser_ReturnsError() - { - var user = TestEntities.User(1, "user"); - var otherUser = TestEntities.User(2, "other"); - var (picture, audio) = SeedIntents(otherUser.Id); - await SeedAsync(user, otherUser, picture, audio); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]); + [Fact] + public async Task Handle_PictureIntentBelongsToAnotherUser_ReturnsError() + { + var user = TestEntities.User(1, "user"); + var otherUser = TestEntities.User(2, "other"); + var (picture, audio) = SeedIntents(otherUser.Id); + await SeedAsync(user, otherUser, picture, audio); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]); - Assert.True(result.IsError); - await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); - } + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AudioIntentAlreadyConsumed_ReturnsError() - { - var user = TestEntities.User(); - var picture = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"); - var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3", status: UploadIntentStatus.Consumed); - await SeedAsync(user, picture, audio); + Assert.True(result.IsError); + await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]); + [Fact] + public async Task Handle_AudioIntentAlreadyConsumed_ReturnsError() + { + var user = TestEntities.User(); + var picture = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"); + var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3", status: UploadIntentStatus.Consumed); + await SeedAsync(user, picture, audio); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]); - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs index 41b449f..184f3d5 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs @@ -3,29 +3,30 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class DeleteStaleUncountedListensCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private DeleteStaleUncountedListensCommandHandler CreateHandler() => - new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_DeletesOnlyOldUncountedListens() + public sealed class DeleteStaleUncountedListensCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - var oldUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 2, isCounted: false); - var oldCounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 100); - var recentUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-2), playedSeconds: 2, isCounted: false); - await SeedAsync(owner, track, oldUncounted, oldCounted, recentUncounted); + private DeleteStaleUncountedListensCommandHandler CreateHandler() => + new(Database, NoOpLogger()); - var deleted = await CreateHandler().Handle(new DeleteStaleUncountedListensCommand(), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_DeletesOnlyOldUncountedListens() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + var oldUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 2, isCounted: false); + var oldCounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 100); + var recentUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-2), playedSeconds: 2, isCounted: false); + await SeedAsync(owner, track, oldUncounted, oldCounted, recentUncounted); - Assert.Equal(1, deleted); - var remaining = await Database.ListeningHistories.Select(l => l.Id).ToListAsync(TestContext.Current.CancellationToken); - Assert.DoesNotContain(oldUncounted.Id, remaining); - Assert.Contains(oldCounted.Id, remaining); - Assert.Contains(recentUncounted.Id, remaining); + var deleted = await CreateHandler().Handle(new DeleteStaleUncountedListensCommand(), TestContext.Current.CancellationToken); + + Assert.Equal(1, deleted); + var remaining = await Database.ListeningHistories.Select(l => l.Id).ToListAsync(TestContext.Current.CancellationToken); + Assert.DoesNotContain(oldUncounted.Id, remaining); + Assert.Contains(oldCounted.Id, remaining); + Assert.Contains(recentUncounted.Id, remaining); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs index 4052fe0..9af73d7 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs @@ -6,60 +6,61 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private readonly IEventBus eventBus = Substitute.For(); - - private DeleteTrackCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); - - [Fact] - public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent() + public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + private readonly IEventBus eventBus = Substitute.For(); - var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); + private DeleteTrackCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); - Assert.False(result.IsError); - var stored = await Database.Tracks.FindAsync([track.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); - } + [Fact] + public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new DeleteTrackCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + Assert.False(result.IsError); + var stored = await Database.Tracks.FindAsync([track.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } - [Fact] - public async Task Handle_NotOwner_ReturnsForbidden() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var track = TestEntities.Track(owner); - await SeedAsync(owner, stranger, track); + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new DeleteTrackCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + [Fact] + public async Task Handle_NotOwner_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var track = TestEntities.Track(owner); + await SeedAsync(owner, stranger, track); - [Fact] - public async Task Handle_AudioStillProcessing_ReturnsConflict() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio()); - await SeedAsync(owner, track); + var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + [Fact] + public async Task Handle_AudioStillProcessing_ReturnsConflict() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio()); + await SeedAsync(owner, track); + + var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs index 822db75..8e80f4a 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs @@ -2,37 +2,38 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class GetLastTrackListenedByUserIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private GetLastTrackListenedByUserIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_MultipleListens_ReturnsMostRecentlyListenedTrack() + public sealed class GetLastTrackListenedByUserIdQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var older = TestEntities.Track(owner, "Older listen"); - var newer = TestEntities.Track(owner, "Newer listen"); - await SeedAsync( - owner, older, newer, - TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)), - TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow)); + private GetLastTrackListenedByUserIdQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_MultipleListens_ReturnsMostRecentlyListenedTrack() + { + var owner = TestEntities.User(); + var older = TestEntities.Track(owner, "Older listen"); + var newer = TestEntities.Track(owner, "Newer listen"); + await SeedAsync( + owner, older, newer, + TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)), + TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow)); - Assert.NotNull(result); - Assert.Equal("Newer listen", result.Title); - } + var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NoHistory_ReturnsNull() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + Assert.NotNull(result); + Assert.Equal("Newer listen", result.Title); + } - var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NoHistory_ReturnsNull() + { + var owner = TestEntities.User(); + await SeedAsync(owner); - Assert.Null(result); + var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.Null(result); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs index d9cf654..1886f27 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs @@ -2,53 +2,54 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class GetListeningHistoryQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private GetListeningHistoryQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_MultipleListens_ReturnsDistinctTracksMostRecentFirst() + public sealed class GetListeningHistoryQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var older = TestEntities.Track(owner, "Older listen"); - var newer = TestEntities.Track(owner, "Newer listen"); - await SeedAsync( - owner, older, newer, - TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)), - TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-5)), - TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow)); + private GetListeningHistoryQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_MultipleListens_ReturnsDistinctTracksMostRecentFirst() + { + var owner = TestEntities.User(); + var older = TestEntities.Track(owner, "Older listen"); + var newer = TestEntities.Track(owner, "Newer listen"); + await SeedAsync( + owner, older, newer, + TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)), + TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-5)), + TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow)); - Assert.Equal(["Newer listen", "Older listen"], result.Select(track => track.Title)); - } + var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NoHistory_ReturnsEmpty() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + Assert.Equal(["Newer listen", "Older listen"], result.Select(track => track.Title)); + } - var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NoHistory_ReturnsEmpty() + { + var owner = TestEntities.User(); + await SeedAsync(owner); - Assert.Empty(result); - } + var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UncountedListens_AreIgnored() - { - var owner = TestEntities.User(); - var counted = TestEntities.Track(owner, "Counted"); - var skipped = TestEntities.Track(owner, "Skipped"); - await SeedAsync( - owner, counted, skipped, - TestEntities.ListeningHistory(owner.Id, counted.Id, DateTime.UtcNow.AddMinutes(-5), playedSeconds: 90), - TestEntities.ListeningHistory(owner.Id, skipped.Id, DateTime.UtcNow, playedSeconds: 2, isCounted: false)); + Assert.Empty(result); + } - var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_UncountedListens_AreIgnored() + { + var owner = TestEntities.User(); + var counted = TestEntities.Track(owner, "Counted"); + var skipped = TestEntities.Track(owner, "Skipped"); + await SeedAsync( + owner, counted, skipped, + TestEntities.ListeningHistory(owner.Id, counted.Id, DateTime.UtcNow.AddMinutes(-5), playedSeconds: 90), + TestEntities.ListeningHistory(owner.Id, skipped.Id, DateTime.UtcNow, playedSeconds: 2, isCounted: false)); - Assert.Equal(["Counted"], result.Select(track => track.Title)); + var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.Equal(["Counted"], result.Select(track => track.Title)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs index 93a1548..4a5798a 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs @@ -4,49 +4,50 @@ using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class GetTrackByIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private GetTrackByIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_LocalTrack_ReturnsItWithOwnerAsArtist() + public sealed class GetTrackByIdQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(1, "artist-name"); - var track = TestEntities.Track(owner, "My Song"); - await SeedAsync(owner, track); + private GetTrackByIdQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_LocalTrack_ReturnsItWithOwnerAsArtist() + { + var owner = TestEntities.User(1, "artist-name"); + var track = TestEntities.Track(owner, "My Song"); + await SeedAsync(owner, track); - Assert.False(result.IsError); - Assert.Equal("My Song", result.Value.Title); - Assert.Equal(owner.Name, result.Value.Artist); - var localResponse = Assert.IsType(result.Value); - Assert.Equal(owner.Id, localResponse.OwnerUserId); - } + var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetTrackByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal("My Song", result.Value.Title); + Assert.Equal(owner.Name, result.Value.Artist); + var localResponse = Assert.IsType(result.Value); + Assert.Equal(owner.Id, localResponse.OwnerUserId); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetTrackByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ListensCount_OnlyCountsCountedListens() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync( - owner, track, - TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100), - TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100), - TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 1, isCounted: false)); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ListensCount_OnlyCountsCountedListens() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync( + owner, track, + TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100), + TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100), + TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 1, isCounted: false)); - Assert.Equal(2, result.Value.ListensCount); + var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Value.ListensCount); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs index 4217572..8a71777 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs @@ -2,26 +2,27 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private GetTracksByUserIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ReturnsOnlyTracksLinkedToThatUser() + public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(1, "owner"); - var other = TestEntities.User(2, "other"); - var mine = TestEntities.Track(owner, "Mine"); - var theirs = TestEntities.Track(other, "Theirs"); - await SeedAsync( - owner, other, mine, theirs); + private GetTracksByUserIdQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ReturnsOnlyTracksLinkedToThatUser() + { + var owner = TestEntities.User(1, "owner"); + var other = TestEntities.User(2, "other"); + var mine = TestEntities.Track(owner, "Mine"); + var theirs = TestEntities.Track(other, "Theirs"); + await SeedAsync( + owner, other, mine, theirs); - Assert.False(result.IsError); - var track = Assert.Single(result.Value.Items); - Assert.Equal("Mine", track.Title); + var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var track = Assert.Single(result.Value.Items); + Assert.Equal("Mine", track.Title); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs index eee38d9..0d5e7b8 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs @@ -3,51 +3,52 @@ using Musify.Application.Tracks; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class GetTracksQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private GetTracksQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns() + public sealed class GetTracksQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var trackA = TestEntities.Track(owner, "Alpha"); - var trackB = TestEntities.Track(owner, "Beta"); - await SeedAsync( - owner, trackA, trackB); + private GetTracksQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - Assert.Equal(2, result.TotalItemCount); - Assert.Equal(2, result.Items.Count); - } + [Fact] + public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns() + { + var owner = TestEntities.User(); + var trackA = TestEntities.Track(owner, "Alpha"); + var trackB = TestEntities.Track(owner, "Beta"); + await SeedAsync( + owner, trackA, trackB); - [Fact] - public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks() - { - var owner = TestEntities.User(); - var match = TestEntities.Track(owner, "Bohemian Rhapsody"); - var other = TestEntities.Track(owner, "Imagine"); - await SeedAsync( - owner, match, other); + var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.Equal(2, result.TotalItemCount); + Assert.Equal(2, result.Items.Count); + } - var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - var item = Assert.Single(result.Items); - Assert.Equal("Bohemian Rhapsody", item.Title); - } + [Fact] + public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks() + { + var owner = TestEntities.User(); + var match = TestEntities.Track(owner, "Bohemian Rhapsody"); + var other = TestEntities.Track(owner, "Imagine"); + await SeedAsync( + owner, match, other); - [Fact] - public async Task Handle_GenreFilter_ReturnsOnlyTracksWithThatTag() - { - var owner = TestEntities.User(); - var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]); - var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]); - await SeedAsync( - owner, rock, jazz); + var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + var item = Assert.Single(result.Items); + Assert.Equal("Bohemian Rhapsody", item.Title); + } - var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken); - var item = Assert.Single(result.Items); - Assert.Equal("Loud", item.Title); + [Fact] + public async Task Handle_GenreFilter_ReturnsOnlyTracksWithThatTag() + { + var owner = TestEntities.User(); + var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]); + var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]); + await SeedAsync( + owner, rock, jazz); + + var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken); + var item = Assert.Single(result.Items); + Assert.Equal("Loud", item.Title); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs index 0db7d21..e24ee6a 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs @@ -5,109 +5,110 @@ using Musify.Application.Tracks; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class RecordListeningProgressCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private RecordListeningProgressCommandHandler CreateHandler() => new(Database); - - private async Task ReloadAsync(Guid id) => - await Database.ListeningHistories.AsNoTracking() - .SingleAsync(l => l.Id == id, TestContext.Current.CancellationToken); - - private async Task<(User User, ListeningHistory Listen)> SeedListenAsync( - double durationSeconds, DateTime listenedAt) + public sealed class RecordListeningProgressCommandHandlerTests : HandlerTestBase { - var user = TestEntities.User(); - var track = TestEntities.Track(user, durationSeconds: durationSeconds); - var listen = TestEntities.ListeningHistory(user.Id, track.Id, listenedAt, isCounted: false); - await SeedAsync(user, track, listen); - return (user, listen); + private RecordListeningProgressCommandHandler CreateHandler() => new(Database); + + private async Task ReloadAsync(Guid id) => + await Database.ListeningHistories.AsNoTracking() + .SingleAsync(l => l.Id == id, TestContext.Current.CancellationToken); + + private async Task<(User User, ListeningHistory Listen)> SeedListenAsync( + double durationSeconds, DateTime listenedAt) + { + var user = TestEntities.User(); + var track = TestEntities.Track(user, durationSeconds: durationSeconds); + var listen = TestEntities.ListeningHistory(user.Id, track.Id, listenedAt, isCounted: false); + await SeedAsync(user, track, listen); + return (user, listen); + } + + [Fact] + public async Task Handle_BelowThreshold_StoresSecondsWithoutCounting() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + + var result = await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var stored = await ReloadAsync(listen.Id); + Assert.Equal(10, stored.PlayedSeconds); + Assert.False(stored.IsCounted); + } + + [Fact] + public async Task Handle_ReachesThirtySeconds_MarksAsCounted() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 30), TestContext.Current.CancellationToken); + + Assert.True((await ReloadAsync(listen.Id)).IsCounted); + } + + [Fact] + public async Task Handle_ShortTrack_CountsAtHalfDuration() + { + var (user, listen) = await SeedListenAsync(20, DateTime.UtcNow.AddSeconds(-60)); + + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken); + + Assert.True((await ReloadAsync(listen.Id)).IsCounted); + } + + [Fact] + public async Task Handle_LowerValueThanStored_KeepsHighest() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + var handler = CreateHandler(); + + await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 40), TestContext.Current.CancellationToken); + await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 15), TestContext.Current.CancellationToken); + + var stored = await ReloadAsync(listen.Id); + Assert.Equal(40, stored.PlayedSeconds); + Assert.True(stored.IsCounted); + } + + [Fact] + public async Task Handle_MoreThanElapsedClock_IsCappedToElapsedTime() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-20)); + + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 150), TestContext.Current.CancellationToken); + + var stored = await ReloadAsync(listen.Id); + Assert.InRange(stored.PlayedSeconds!.Value, 24, 27); + } + + [Fact] + public async Task Handle_MoreThanTrackDuration_IsCappedToDuration() + { + var (user, listen) = await SeedListenAsync(50, DateTime.UtcNow.AddMinutes(-30)); + + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 900), TestContext.Current.CancellationToken); + + Assert.Equal(52, (await ReloadAsync(listen.Id)).PlayedSeconds); + } + + [Fact] + public async Task Handle_OtherUsersListen_ReturnsNotFound() + { + var (_, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + + var result = await CreateHandler().Handle( + new RecordListeningProgressCommand(long.MaxValue, listen.Id, 10), TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } - - [Fact] - public async Task Handle_BelowThreshold_StoresSecondsWithoutCounting() - { - var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); - - var result = await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var stored = await ReloadAsync(listen.Id); - Assert.Equal(10, stored.PlayedSeconds); - Assert.False(stored.IsCounted); - } - - [Fact] - public async Task Handle_ReachesThirtySeconds_MarksAsCounted() - { - var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); - - await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, 30), TestContext.Current.CancellationToken); - - Assert.True((await ReloadAsync(listen.Id)).IsCounted); - } - - [Fact] - public async Task Handle_ShortTrack_CountsAtHalfDuration() - { - var (user, listen) = await SeedListenAsync(20, DateTime.UtcNow.AddSeconds(-60)); - - await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken); - - Assert.True((await ReloadAsync(listen.Id)).IsCounted); - } - - [Fact] - public async Task Handle_LowerValueThanStored_KeepsHighest() - { - var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); - var handler = CreateHandler(); - - await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 40), TestContext.Current.CancellationToken); - await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 15), TestContext.Current.CancellationToken); - - var stored = await ReloadAsync(listen.Id); - Assert.Equal(40, stored.PlayedSeconds); - Assert.True(stored.IsCounted); - } - - [Fact] - public async Task Handle_MoreThanElapsedClock_IsCappedToElapsedTime() - { - var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-20)); - - await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, 150), TestContext.Current.CancellationToken); - - var stored = await ReloadAsync(listen.Id); - Assert.InRange(stored.PlayedSeconds!.Value, 24, 27); - } - - [Fact] - public async Task Handle_MoreThanTrackDuration_IsCappedToDuration() - { - var (user, listen) = await SeedListenAsync(50, DateTime.UtcNow.AddMinutes(-30)); - - await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, 900), TestContext.Current.CancellationToken); - - Assert.Equal(52, (await ReloadAsync(listen.Id)).PlayedSeconds); - } - - [Fact] - public async Task Handle_OtherUsersListen_ReturnsNotFound() - { - var (_, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); - - var result = await CreateHandler().Handle( - new RecordListeningProgressCommand(long.MaxValue, listen.Id, 10), TestContext.Current.CancellationToken); - - Assert.True(result.IsError); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } - } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs index 33376ef..31aed91 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs @@ -9,77 +9,78 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private readonly IStorageService storageService = Substitute.For(); - - private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase { - storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns("https://storage.musify.test/presigned-upload"); + private readonly IStorageService storageService = Substitute.For(); - var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); + private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + { + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); - return new RequestTrackUploadUrlsCommandHandler( - new UploadIntentIssuer( - Database, - storageService, - new UploadIntentValidator(Database, storageService, config), + var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); + + return new RequestTrackUploadUrlsCommandHandler( + new UploadIntentIssuer( + Database, + storageService, + new UploadIntentValidator(Database, storageService, config), + TestConfigurations.Storage(), + config, + NoOpLogger()), TestConfigurations.Storage(), - config, - NoOpLogger()), - TestConfigurations.Storage(), - TestConfigurations.Track(), - config); - } + TestConfigurations.Track(), + config); + } - [Fact] - public async Task Handle_UserExists_IssuesBothUploadUrlsAndCreatesIntents() - { - var user = TestEntities.User(); - await SeedAsync(user); + [Fact] + public async Task Handle_UserExists_IssuesBothUploadUrlsAndCreatesIntents() + { + var user = TestEntities.User(); + await SeedAsync(user); - var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg"); + var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg"); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - Assert.False(result.IsError); - Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.PictureUploadUrl); - Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.AudioUploadUrl); + Assert.False(result.IsError); + Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.PictureUploadUrl); + Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.AudioUploadUrl); - var intents = await Database.UploadIntents.Where(intent => intent.UserId == user.Id).ToListAsync(TestContext.Current.CancellationToken); - Assert.Equal(2, intents.Count); - Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackPicture); - Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackAudio); - } + var intents = await Database.UploadIntents.Where(intent => intent.UserId == user.Id).ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, intents.Count); + Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackPicture); + Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackAudio); + } - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var command = new RequestTrackUploadUrlsCommand(404, "webp", "image/webp", "mp3", "audio/mpeg"); + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new RequestTrackUploadUrlsCommand(404, "webp", "image/webp", "mp3", "audio/mpeg"); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - [Fact] - public async Task Handle_UserAtActiveIntentLimit_ReturnsValidationErrorAndCreatesNothing() - { - var config = TestConfigurations.UploadIntent(); - config.MaxActiveUploadIntentsPerUser = 1; + [Fact] + public async Task Handle_UserAtActiveIntentLimit_ReturnsValidationErrorAndCreatesNothing() + { + var config = TestConfigurations.UploadIntent(); + config.MaxActiveUploadIntentsPerUser = 1; - var user = TestEntities.User(); - await SeedAsync(user, TestEntities.UploadIntent(user.Id)); + var user = TestEntities.User(); + await SeedAsync(user, TestEntities.UploadIntent(user.Id)); - var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg"); + var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg"); - var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken); + var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - Assert.Equal(1, await Database.UploadIntents.CountAsync(TestContext.Current.CancellationToken)); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + Assert.Equal(1, await Database.UploadIntents.CountAsync(TestContext.Current.CancellationToken)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/StartListeningCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/StartListeningCommandHandlerTests.cs index 87bc5eb..dd2f28f 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/StartListeningCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/StartListeningCommandHandlerTests.cs @@ -8,94 +8,95 @@ using Musify.Application.Tracks; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class StartListeningCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private readonly IStreamTicketService ticketService = Substitute.For(); - - private StartListeningCommandHandler CreateHandler(PlaybackConfiguration? playback = null) + public sealed class StartListeningCommandHandlerTests : HandlerTestBase { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("ticket-token", 60)); + private readonly IStreamTicketService ticketService = Substitute.For(); - var issuer = new TrackStreamIssuer( - ticketService, - TestConfigurations.Track(), - TestConfigurations.StreamGateway("https://stream.musify.test"), - playback ?? TestConfigurations.Playback()); + private StartListeningCommandHandler CreateHandler(PlaybackConfiguration? playback = null) + { + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("ticket-token", 60)); - return new StartListeningCommandHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger()); - } + var issuer = new TrackStreamIssuer( + ticketService, + TestConfigurations.Track(), + TestConfigurations.StreamGateway("https://stream.musify.test"), + playback ?? TestConfigurations.Playback()); - [Fact] - public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + return new StartListeningCommandHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger()); + } - var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - Assert.False(result.IsError); - Assert.Equal("ticket-token", result.Value.Ticket); - Assert.Equal(60, result.Value.ExpiresInSeconds); - Assert.Contains("audio-folder", result.Value.ManifestUrl); + var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken); - var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Equal(owner.Id, history.UserId); - Assert.Equal(track.Id, history.TrackId); - Assert.Equal(history.Id, result.Value.ListenId); - Assert.False(history.IsCounted); - Assert.Null(history.PlayedSeconds); - } + Assert.False(result.IsError); + Assert.Equal("ticket-token", result.Value.Ticket); + Assert.Equal(60, result.Value.ExpiresInSeconds); + Assert.Contains("audio-folder", result.Value.ManifestUrl); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new StartListeningCommand(Guid.NewGuid(), 1), TestContext.Current.CancellationToken); + var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal(owner.Id, history.UserId); + Assert.Equal(track.Id, history.TrackId); + Assert.Equal(history.Id, result.Value.ListenId); + Assert.False(history.IsCounted); + Assert.Null(history.PlayedSeconds); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new StartListeningCommand(Guid.NewGuid(), 1), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AudioNotProcessedYet_ReturnsConflict() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio()); - await SeedAsync(owner, track); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_AudioNotProcessedYet_ReturnsConflict() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio()); + await SeedAsync(owner, track); - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + } - var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false)); + [Fact] + public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken); + var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false)); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - } + var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AnonymousUser_AnonymousListeningEnabled_IssuesTicketWithoutRecordingHistory() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } - var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true)); + [Fact] + public async Task Handle_AnonymousUser_AnonymousListeningEnabled_IssuesTicketWithoutRecordingHistory() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken); + var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true)); - Assert.False(result.IsError); - Assert.Equal("ticket-token", result.Value.Ticket); - Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); + var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("ticket-token", result.Value.Ticket); + Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs index 45f604b..389cbab 100644 --- a/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs @@ -6,66 +6,67 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.UploadIntents; - -public sealed class UploadIntentJobsCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.UploadIntents { - [Fact] - public async Task ExpireUploadIntents_OverdueIssuedIntent_IsMarkedExpired() + public sealed class UploadIntentJobsCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var overdue = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-5)); - var active = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(5)); - await SeedAsync(owner, overdue, active); - var handler = new ExpireUploadIntentsCommandHandler( - Database, TestConfigurations.UploadIntent(), NoOpLogger()); + [Fact] + public async Task ExpireUploadIntents_OverdueIssuedIntent_IsMarkedExpired() + { + var owner = TestEntities.User(); + var overdue = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-5)); + var active = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(5)); + await SeedAsync(owner, overdue, active); + var handler = new ExpireUploadIntentsCommandHandler( + Database, TestConfigurations.UploadIntent(), NoOpLogger()); - var changed = await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken); + var changed = await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken); - Assert.Equal(1, changed); - var stored = await Database.UploadIntents.AsNoTracking().ToListAsync(TestContext.Current.CancellationToken); - Assert.Equal(UploadIntentStatus.Expired, stored.Single(intent => intent.Id == overdue.Id).Status); - Assert.Equal(UploadIntentStatus.Issued, stored.Single(intent => intent.Id == active.Id).Status); - } + Assert.Equal(1, changed); + var stored = await Database.UploadIntents.AsNoTracking().ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(UploadIntentStatus.Expired, stored.Single(intent => intent.Id == overdue.Id).Status); + Assert.Equal(UploadIntentStatus.Issued, stored.Single(intent => intent.Id == active.Id).Status); + } - [Fact] - public async Task ExpireUploadIntents_IntentExpiredLongAgo_IsDeleted() - { - var owner = TestEntities.User(); - var old = TestEntities.UploadIntent( - owner.Id, status: UploadIntentStatus.Expired, expiresAt: DateTime.UtcNow.AddDays(-30)); - await SeedAsync(owner, old); - var handler = new ExpireUploadIntentsCommandHandler( - Database, TestConfigurations.UploadIntent(), NoOpLogger()); + [Fact] + public async Task ExpireUploadIntents_IntentExpiredLongAgo_IsDeleted() + { + var owner = TestEntities.User(); + var old = TestEntities.UploadIntent( + owner.Id, status: UploadIntentStatus.Expired, expiresAt: DateTime.UtcNow.AddDays(-30)); + await SeedAsync(owner, old); + var handler = new ExpireUploadIntentsCommandHandler( + Database, TestConfigurations.UploadIntent(), NoOpLogger()); - await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken); + await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken); - Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); - } + Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } - [Fact] - public async Task DeleteStaleTempUploads_RemovesOnlyObjectsOlderThanTheRetention() - { - var storage = Substitute.For(); - storage.ListObjectsAsync("test-bucket", "temp", Arg.Any()).Returns(Objects( - new StorageObject("temp/old", DateTime.UtcNow.AddDays(-10)), - new StorageObject("temp/new", DateTime.UtcNow))); - var handler = new DeleteStaleTempUploadsCommandHandler( - storage, TestConfigurations.Storage("test-bucket"), TestConfigurations.UploadIntent(), - NoOpLogger()); + [Fact] + public async Task DeleteStaleTempUploads_RemovesOnlyObjectsOlderThanTheRetention() + { + var storage = Substitute.For(); + storage.ListObjectsAsync("test-bucket", "temp", Arg.Any()).Returns(Objects( + new StorageObject("temp/old", DateTime.UtcNow.AddDays(-10)), + new StorageObject("temp/new", DateTime.UtcNow))); + var handler = new DeleteStaleTempUploadsCommandHandler( + storage, TestConfigurations.Storage("test-bucket"), TestConfigurations.UploadIntent(), + NoOpLogger()); - var deleted = await handler.Handle(new DeleteStaleTempUploadsCommand(), TestContext.Current.CancellationToken); + var deleted = await handler.Handle(new DeleteStaleTempUploadsCommand(), TestContext.Current.CancellationToken); - Assert.Equal(1, deleted); - await storage.Received(1).RemoveFileAsync("test-bucket", "temp/old", Arg.Any()); - await storage.DidNotReceive().RemoveFileAsync("test-bucket", "temp/new", Arg.Any()); - } + Assert.Equal(1, deleted); + await storage.Received(1).RemoveFileAsync("test-bucket", "temp/old", Arg.Any()); + await storage.DidNotReceive().RemoveFileAsync("test-bucket", "temp/new", Arg.Any()); + } - private static async IAsyncEnumerable Objects(params StorageObject[] objects) - { - foreach (var item in objects) - yield return item; + private static async IAsyncEnumerable Objects(params StorageObject[] objects) + { + foreach (var item in objects) + yield return item; - await Task.CompletedTask; + await Task.CompletedTask; + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs index 3a17c25..3659bbf 100644 --- a/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs @@ -4,57 +4,58 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class FollowUserCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private FollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_ValidTarget_CreatesFollow() + public sealed class FollowUserCommandHandlerTests : HandlerTestBase { - var follower = TestEntities.User(1, "follower"); - var followed = TestEntities.User(2, "followed"); - await SeedAsync(follower, followed); + private FollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ValidTarget_CreatesFollow() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed); - Assert.False(result.IsError); - Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlreadyFollowing_IsIdempotent() - { - var follower = TestEntities.User(1, "follower"); - var followed = TestEntities.User(2, "followed"); - await SeedAsync(follower, followed, new Musify.Domain.Entities.UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); + Assert.False(result.IsError); + Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_AlreadyFollowing_IsIdempotent() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed, new Musify.Domain.Entities.UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); - Assert.False(result.IsError); - Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_SelfFollow_ReturnsValidationError() - { - var user = TestEntities.User(); - await SeedAsync(user); + Assert.False(result.IsError); + Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await CreateHandler().Handle(new FollowUserCommand(user.Id, user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_SelfFollow_ReturnsValidationError() + { + var user = TestEntities.User(); + await SeedAsync(user); - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new FollowUserCommand(user.Id, user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TargetMissing_ReturnsNotFound() - { - var follower = TestEntities.User(); - await SeedAsync(follower); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, 404), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TargetMissing_ReturnsNotFound() + { + var follower = TestEntities.User(); + await SeedAsync(follower); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, 404), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs index 77a3850..55910f1 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs @@ -2,130 +2,131 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private GetListeningStatsQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_NoHistory_ReturnsZeroes() + public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase { - var user = TestEntities.User(); - await SeedAsync(user); + private GetListeningStatsQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NoHistory_ReturnsZeroes() + { + var user = TestEntities.User(); + await SeedAsync(user); - Assert.Equal(0, result.TracksThisWeek); - Assert.Equal(0, result.SecondsThisWeek); - Assert.Equal(0, result.StreakDays); - } + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ListensThisWeek_CountsDistinctTracksAndTotalSeconds() - { - var user = TestEntities.User(); - var trackA = TestEntities.Track(user, "Track A", durationSeconds: 200); - var trackB = TestEntities.Track(user, "Track B", durationSeconds: 100); - await SeedAsync( - user, trackA, trackB, - TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddDays(-1)), - TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddHours(-1)), - TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow), - TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow.AddDays(-10))); + Assert.Equal(0, result.TracksThisWeek); + Assert.Equal(0, result.SecondsThisWeek); + Assert.Equal(0, result.StreakDays); + } - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ListensThisWeek_CountsDistinctTracksAndTotalSeconds() + { + var user = TestEntities.User(); + var trackA = TestEntities.Track(user, "Track A", durationSeconds: 200); + var trackB = TestEntities.Track(user, "Track B", durationSeconds: 100); + await SeedAsync( + user, trackA, trackB, + TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddDays(-1)), + TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddHours(-1)), + TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow), + TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow.AddDays(-10))); - Assert.Equal(2, result.TracksThisWeek); - Assert.Equal(500, result.SecondsThisWeek); - } + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayedSeconds_SumsPlayedTimeInsteadOfDuration() - { - var user = TestEntities.User(); - var track = TestEntities.Track(user, durationSeconds: 200); - await SeedAsync( - user, track, - TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 200), - TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 12, isCounted: false)); + Assert.Equal(2, result.TracksThisWeek); + Assert.Equal(500, result.SecondsThisWeek); + } - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PlayedSeconds_SumsPlayedTimeInsteadOfDuration() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user, durationSeconds: 200); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 200), + TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 12, isCounted: false)); - Assert.Equal(212, result.SecondsThisWeek); - } + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UncountedListens_AreExcludedFromTracksAndStreak() - { - var user = TestEntities.User(); - var track = TestEntities.Track(user); - await SeedAsync( - user, track, - TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 3, isCounted: false)); + Assert.Equal(212, result.SecondsThisWeek); + } - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_UncountedListens_AreExcludedFromTracksAndStreak() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 3, isCounted: false)); - Assert.Equal(0, result.TracksThisWeek); - Assert.Equal(0, result.StreakDays); - Assert.Equal(3, result.SecondsThisWeek); - } + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo() - { - var user = TestEntities.User(); - var track = TestEntities.Track(user); - await SeedAsync( - user, track, - TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow), - TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1))); + Assert.Equal(0, result.TracksThisWeek); + Assert.Equal(0, result.StreakDays); + Assert.Equal(3, result.SecondsThisWeek); + } - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow), + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1))); - Assert.Equal(2, result.StreakDays); - } + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_GapBeforeToday_StopsStreakAtGap() - { - var user = TestEntities.User(); - var track = TestEntities.Track(user); - await SeedAsync( - user, track, - TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow), - TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2))); + Assert.Equal(2, result.StreakDays); + } - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_GapBeforeToday_StopsStreakAtGap() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow), + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2))); - Assert.Equal(1, result.StreakDays); - } + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ListenedYesterdayButNotToday_StreakIsStillAlive() - { - var user = TestEntities.User(); - var track = TestEntities.Track(user); - await SeedAsync( - user, track, - TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1))); + Assert.Equal(1, result.StreakDays); + } - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ListenedYesterdayButNotToday_StreakIsStillAlive() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1))); - Assert.Equal(1, result.StreakDays); - } + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NoListenTodayOrYesterday_StreakIsZero() - { - var user = TestEntities.User(); - var track = TestEntities.Track(user); - await SeedAsync( - user, track, - TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2))); + Assert.Equal(1, result.StreakDays); + } - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NoListenTodayOrYesterday_StreakIsZero() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2))); - Assert.Equal(0, result.StreakDays); + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(0, result.StreakDays); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs index 2bcb0ac..1854be6 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs @@ -3,29 +3,30 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class GetUserByIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private GetUserByIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ExistingUser_ReturnsIt() + public sealed class GetUserByIdQueryHandlerTests : HandlerTestBase { - var user = TestEntities.User(1, "Jane Doe"); - await SeedAsync(user); + private GetUserByIdQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetUserByIdQuery(1), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ExistingUser_ReturnsIt() + { + var user = TestEntities.User(1, "Jane Doe"); + await SeedAsync(user); - Assert.False(result.IsError); - Assert.Equal("Jane Doe", result.Value.Name); - } + var result = await CreateHandler().Handle(new GetUserByIdQuery(1), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetUserByIdQuery(404), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal("Jane Doe", result.Value.Name); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetUserByIdQuery(404), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserFollowersQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowersQueryHandlerTests.cs index 4778b07..825e7ff 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUserFollowersQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowersQueryHandlerTests.cs @@ -6,89 +6,90 @@ using Musify.Application.Users.Responses; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class GetUserFollowersQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private GetUserFollowersQueryHandler CreateHandler() => new(Database); - - private async Task>> Send(long? viewerId, long userId) => - await CreateHandler().Handle(new GetUserFollowersQuery(viewerId, userId, 1, 20), TestContext.Current.CancellationToken); - - [Fact] - public async Task Handle_OwnProfile_ReturnsFollowers() + public sealed class GetUserFollowersQueryHandlerTests : HandlerTestBase { - var user = TestEntities.User(1, "user"); - var follower = TestEntities.User(2, "follower"); - await SeedAsync(user, follower, new UserFollow { FollowerId = follower.Id, FollowedId = user.Id }); + private GetUserFollowersQueryHandler CreateHandler() => new(Database); - var result = await Send(user.Id, user.Id); + private async Task>> Send(long? viewerId, long userId) => + await CreateHandler().Handle(new GetUserFollowersQuery(viewerId, userId, 1, 20), TestContext.Current.CancellationToken); - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.Equal(follower.Id, item.Id); - Assert.False(item.IsFollowedByViewer); - } + [Fact] + public async Task Handle_OwnProfile_ReturnsFollowers() + { + var user = TestEntities.User(1, "user"); + var follower = TestEntities.User(2, "follower"); + await SeedAsync(user, follower, new UserFollow { FollowerId = follower.Id, FollowedId = user.Id }); - [Fact] - public async Task Handle_MutualFollow_ReturnsFollowersWithViewerFollowState() - { - var viewer = TestEntities.User(1, "viewer"); - var user = TestEntities.User(2, "user"); - var other = TestEntities.User(3, "other"); - await SeedAsync( - viewer, user, other, - new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }, - new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }, - new UserFollow { FollowerId = other.Id, FollowedId = user.Id }, - new UserFollow { FollowerId = viewer.Id, FollowedId = other.Id }); + var result = await Send(user.Id, user.Id); - var result = await Send(viewer.Id, user.Id); + Assert.False(result.IsError); + var item = Assert.Single(result.Value.Items); + Assert.Equal(follower.Id, item.Id); + Assert.False(item.IsFollowedByViewer); + } - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - Assert.All(result.Value.Items, item => Assert.Equal(item.Id == other.Id, item.IsFollowedByViewer)); - } + [Fact] + public async Task Handle_MutualFollow_ReturnsFollowersWithViewerFollowState() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + var other = TestEntities.User(3, "other"); + await SeedAsync( + viewer, user, other, + new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }, + new UserFollow { FollowerId = other.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = viewer.Id, FollowedId = other.Id }); - [Fact] - public async Task Handle_ViewerFollowsButNotFollowedBack_ReturnsForbidden() - { - var viewer = TestEntities.User(1, "viewer"); - var user = TestEntities.User(2, "user"); - await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }); + var result = await Send(viewer.Id, user.Id); - var result = await Send(viewer.Id, user.Id); + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + Assert.All(result.Value.Items, item => Assert.Equal(item.Id == other.Id, item.IsFollowedByViewer)); + } - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + [Fact] + public async Task Handle_ViewerFollowsButNotFollowedBack_ReturnsForbidden() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }); - [Fact] - public async Task Handle_FollowedByUserButNotFollowingBack_ReturnsForbidden() - { - var viewer = TestEntities.User(1, "viewer"); - var user = TestEntities.User(2, "user"); - await SeedAsync(viewer, user, new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }); + var result = await Send(viewer.Id, user.Id); - var result = await Send(viewer.Id, user.Id); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + [Fact] + public async Task Handle_FollowedByUserButNotFollowingBack_ReturnsForbidden() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + await SeedAsync(viewer, user, new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }); - [Fact] - public async Task Handle_Anonymous_ReturnsForbidden() - { - await SeedAsync(TestEntities.User(1, "user")); + var result = await Send(viewer.Id, user.Id); - var result = await Send(null, 1); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + [Fact] + public async Task Handle_Anonymous_ReturnsForbidden() + { + await SeedAsync(TestEntities.User(1, "user")); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var result = await Send(1, 404); + var result = await Send(null, 1); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } + + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await Send(1, 404); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs index 580ebd5..c074d00 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs @@ -4,71 +4,72 @@ using Musify.Application.Users; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class GetUserFollowingQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private GetUserFollowingQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_Anonymous_ReturnsFollowedUsers() + public sealed class GetUserFollowingQueryHandlerTests : HandlerTestBase { - var user = TestEntities.User(1, "user"); - var followedA = TestEntities.User(2, "followedA"); - var followedB = TestEntities.User(3, "followedB"); - await SeedAsync( - user, followedA, followedB, - new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id }, - new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id }); + private GetUserFollowingQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, user.Id, 1, 20), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_Anonymous_ReturnsFollowedUsers() + { + var user = TestEntities.User(1, "user"); + var followedA = TestEntities.User(2, "followedA"); + var followedB = TestEntities.User(3, "followedB"); + await SeedAsync( + user, followedA, followedB, + new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id }); - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - Assert.All(result.Value.Items, item => Assert.False(item.IsFollowedByViewer)); - } + var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, user.Id, 1, 20), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_Viewer_MarksUsersTheViewerFollows() - { - var viewer = TestEntities.User(1, "viewer"); - var user = TestEntities.User(2, "user"); - var followedA = TestEntities.User(3, "followedA"); - var followedB = TestEntities.User(4, "followedB"); - await SeedAsync( - viewer, user, followedA, followedB, - new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id }, - new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id }, - new UserFollow { FollowerId = viewer.Id, FollowedId = followedA.Id }); + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + Assert.All(result.Value.Items, item => Assert.False(item.IsFollowedByViewer)); + } - var result = await CreateHandler().Handle(new GetUserFollowingQuery(viewer.Id, user.Id, 1, 20), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_Viewer_MarksUsersTheViewerFollows() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + var followedA = TestEntities.User(3, "followedA"); + var followedB = TestEntities.User(4, "followedB"); + await SeedAsync( + viewer, user, followedA, followedB, + new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id }, + new UserFollow { FollowerId = viewer.Id, FollowedId = followedA.Id }); - Assert.False(result.IsError); - Assert.All(result.Value.Items, item => Assert.Equal(item.Id == followedA.Id, item.IsFollowedByViewer)); - } + var result = await CreateHandler().Handle(new GetUserFollowingQuery(viewer.Id, user.Id, 1, 20), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_Pagination_ReturnsRequestedPage() - { - await SeedAsync( - TestEntities.User(1, "user"), TestEntities.User(2, "a"), TestEntities.User(3, "b"), TestEntities.User(4, "c"), - new UserFollow { FollowerId = 1, FollowedId = 2 }, - new UserFollow { FollowerId = 1, FollowedId = 3 }, - new UserFollow { FollowerId = 1, FollowedId = 4 }); + Assert.False(result.IsError); + Assert.All(result.Value.Items, item => Assert.Equal(item.Id == followedA.Id, item.IsFollowedByViewer)); + } - var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 1, 2, 2), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_Pagination_ReturnsRequestedPage() + { + await SeedAsync( + TestEntities.User(1, "user"), TestEntities.User(2, "a"), TestEntities.User(3, "b"), TestEntities.User(4, "c"), + new UserFollow { FollowerId = 1, FollowedId = 2 }, + new UserFollow { FollowerId = 1, FollowedId = 3 }, + new UserFollow { FollowerId = 1, FollowedId = 4 }); - Assert.False(result.IsError); - Assert.Single(result.Value.Items); - Assert.Equal(3, result.Value.TotalItemCount); - Assert.False(result.Value.HasNextPage); - } + var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 1, 2, 2), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 404, 1, 20), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Single(result.Value.Items); + Assert.Equal(3, result.Value.TotalItemCount); + Assert.False(result.Value.HasNextPage); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 404, 1, 20), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs index e7e40d8..45665aa 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs @@ -4,77 +4,78 @@ using Musify.Application.Users; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class GetUserProfileQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private GetUserProfileQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ExistingUser_ReturnsFollowerAndFollowingCounts() + public sealed class GetUserProfileQueryHandlerTests : HandlerTestBase { - var user = TestEntities.User(1, "user"); - var followerA = TestEntities.User(2, "followerA"); - var followerB = TestEntities.User(3, "followerB"); - var followed = TestEntities.User(4, "followed"); - await SeedAsync( - user, followerA, followerB, followed, - new UserFollow { FollowerId = followerA.Id, FollowedId = user.Id }, - new UserFollow { FollowerId = followerB.Id, FollowedId = user.Id }, - new UserFollow { FollowerId = user.Id, FollowedId = followed.Id }); + private GetUserProfileQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ExistingUser_ReturnsFollowerAndFollowingCounts() + { + var user = TestEntities.User(1, "user"); + var followerA = TestEntities.User(2, "followerA"); + var followerB = TestEntities.User(3, "followerB"); + var followed = TestEntities.User(4, "followed"); + await SeedAsync( + user, followerA, followerB, followed, + new UserFollow { FollowerId = followerA.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = followerB.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = followed.Id }); - Assert.False(result.IsError); - Assert.Equal(2, result.Value.FollowersCount); - Assert.Equal(1, result.Value.FollowingCount); - } + var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_MutualFollow_ViewerCanSeeFollowersAndIsFollowing() - { - var viewer = TestEntities.User(1, "viewer"); - var user = TestEntities.User(2, "user"); - await SeedAsync( - viewer, user, - new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }, - new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }); + Assert.False(result.IsError); + Assert.Equal(2, result.Value.FollowersCount); + Assert.Equal(1, result.Value.FollowingCount); + } - var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_MutualFollow_ViewerCanSeeFollowersAndIsFollowing() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + await SeedAsync( + viewer, user, + new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }); - Assert.True(result.Value.IsFollowing); - Assert.True(result.Value.CanViewFollowers); - } + var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_OneWayFollow_ViewerCannotSeeFollowers() - { - var viewer = TestEntities.User(1, "viewer"); - var user = TestEntities.User(2, "user"); - await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }); + Assert.True(result.Value.IsFollowing); + Assert.True(result.Value.CanViewFollowers); + } - var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_OneWayFollow_ViewerCannotSeeFollowers() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }); - Assert.True(result.Value.IsFollowing); - Assert.False(result.Value.CanViewFollowers); - } + var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_OwnProfile_CanSeeFollowersButIsNotFollowing() - { - await SeedAsync(TestEntities.User(1, "user")); + Assert.True(result.Value.IsFollowing); + Assert.False(result.Value.CanViewFollowers); + } - var result = await CreateHandler().Handle(new GetUserProfileQuery(1, 1), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_OwnProfile_CanSeeFollowersButIsNotFollowing() + { + await SeedAsync(TestEntities.User(1, "user")); - Assert.False(result.Value.IsFollowing); - Assert.True(result.Value.CanViewFollowers); - } + var result = await CreateHandler().Handle(new GetUserProfileQuery(1, 1), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetUserProfileQuery(404), TestContext.Current.CancellationToken); + Assert.False(result.Value.IsFollowing); + Assert.True(result.Value.CanViewFollowers); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetUserProfileQuery(404), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs index 9734a7b..20a5f2e 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs @@ -3,39 +3,40 @@ using Musify.Application.Users; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class GetUsersQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private GetUsersQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_NoSearch_ReturnsEveryUser() + public sealed class GetUsersQueryHandlerTests : HandlerTestBase { - await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob")); + private GetUsersQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), TestContext.Current.CancellationToken); - Assert.Equal(2, result.TotalItemCount); - } + [Fact] + public async Task Handle_NoSearch_ReturnsEveryUser() + { + await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob")); - [Fact] - public async Task Handle_UsernameSearch_ReturnsOnlyMatches() - { - await SeedAsync(TestEntities.User(1, "alice-wonderland"), TestEntities.User(2, "bob-builder")); + var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), TestContext.Current.CancellationToken); + Assert.Equal(2, result.TotalItemCount); + } - var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), TestContext.Current.CancellationToken); - var user = Assert.Single(result.Items); - Assert.Equal(1, user.Id); - } + [Fact] + public async Task Handle_UsernameSearch_ReturnsOnlyMatches() + { + await SeedAsync(TestEntities.User(1, "alice-wonderland"), TestEntities.User(2, "bob-builder")); - [Fact] - public async Task Handle_Viewer_MarksFollowedUsers() - { - await SeedAsync( - TestEntities.User(1, "viewer"), TestEntities.User(2, "alice"), TestEntities.User(3, "bob"), - new UserFollow { FollowerId = 1, FollowedId = 2 }); + var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), TestContext.Current.CancellationToken); + var user = Assert.Single(result.Items); + Assert.Equal(1, user.Id); + } - var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken); - Assert.All(result.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer)); + [Fact] + public async Task Handle_Viewer_MarksFollowedUsers() + { + await SeedAsync( + TestEntities.User(1, "viewer"), TestEntities.User(2, "alice"), TestEntities.User(3, "bob"), + new UserFollow { FollowerId = 1, FollowedId = 2 }); + + var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken); + Assert.All(result.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/IsFollowingUserQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/IsFollowingUserQueryHandlerTests.cs index 48650e0..2d7f9ea 100644 --- a/backend/Tests/Musify.Application.Tests/Users/IsFollowingUserQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/IsFollowingUserQueryHandlerTests.cs @@ -3,33 +3,34 @@ using Musify.Application.Users; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class IsFollowingUserQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private IsFollowingUserQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_Following_ReturnsTrue() + public sealed class IsFollowingUserQueryHandlerTests : HandlerTestBase { - var follower = TestEntities.User(1, "follower"); - var followed = TestEntities.User(2, "followed"); - await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); + private IsFollowingUserQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_Following_ReturnsTrue() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); - Assert.True(result); - } + var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NotFollowing_ReturnsFalse() - { - var follower = TestEntities.User(1, "follower"); - var followed = TestEntities.User(2, "followed"); - await SeedAsync(follower, followed); + Assert.True(result); + } - var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NotFollowing_ReturnsFalse() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed); - Assert.False(result); + var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken); + + Assert.False(result); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs index 4a73c0f..3702165 100644 --- a/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs @@ -2,41 +2,42 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class SyncUserCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private SyncUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_UnknownUser_ProvisionsIt() + public sealed class SyncUserCommandHandlerTests : HandlerTestBase { - var command = new SyncUserCommand(1, "Jane Doe", "Jane", "Doe", "https://avatar.example/jane.png"); + private SyncUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_UnknownUser_ProvisionsIt() + { + var command = new SyncUserCommand(1, "Jane Doe", "Jane", "Doe", "https://avatar.example/jane.png"); - Assert.False(result.IsError); - var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("Jane Doe", stored.Name); - Assert.Equal("https://avatar.example/jane.png", stored.ProfilePictureUrl); - } + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ExistingUser_UpdatesProfileFields() - { - var user = TestEntities.User(1, "Old Name", profilePictureUrl: null); - await SeedAsync(user); + Assert.False(result.IsError); + var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("Jane Doe", stored.Name); + Assert.Equal("https://avatar.example/jane.png", stored.ProfilePictureUrl); + } - var command = new SyncUserCommand(1, "New Name", "New", "Name", "https://avatar.example/new.png"); + [Fact] + public async Task Handle_ExistingUser_UpdatesProfileFields() + { + var user = TestEntities.User(1, "Old Name", profilePictureUrl: null); + await SeedAsync(user); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var command = new SyncUserCommand(1, "New Name", "New", "Name", "https://avatar.example/new.png"); - Assert.False(result.IsError); - var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("New Name", stored.Name); - Assert.Equal("NEW NAME", stored.NormalizedName); - Assert.Equal("https://avatar.example/new.png", stored.ProfilePictureUrl); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("New Name", stored.Name); + Assert.Equal("NEW NAME", stored.NormalizedName); + Assert.Equal("https://avatar.example/new.png", stored.ProfilePictureUrl); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/UnfollowUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/UnfollowUserCommandHandlerTests.cs index 7d24067..e853dd8 100644 --- a/backend/Tests/Musify.Application.Tests/Users/UnfollowUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/UnfollowUserCommandHandlerTests.cs @@ -4,34 +4,35 @@ using Musify.Application.Users; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class UnfollowUserCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private UnfollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_ExistingFollow_RemovesIt() + public sealed class UnfollowUserCommandHandlerTests : HandlerTestBase { - var follower = TestEntities.User(1, "follower"); - var followed = TestEntities.User(2, "followed"); - await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); + private UnfollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ExistingFollow_RemovesIt() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); - Assert.False(result.IsError); - Assert.Empty(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NotFollowing_IsIdempotent() - { - var follower = TestEntities.User(1, "follower"); - var followed = TestEntities.User(2, "followed"); - await SeedAsync(follower, followed); + Assert.False(result.IsError); + Assert.Empty(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NotFollowing_IsIdempotent() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed); - Assert.False(result.IsError); + var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } } } diff --git a/backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs b/backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs index 56784fc..1aa3589 100644 --- a/backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs +++ b/backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs @@ -1,50 +1,51 @@ using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Domain.Tests.ValueObjects; - -public sealed class GenreCompatibilityTests +namespace Musify.Domain.Tests.ValueObjects { - [Theory] - [InlineData(Genre.Classical, Genre.Metal)] - [InlineData(Genre.Classical, Genre.Punk)] - [InlineData(Genre.Ambient, Genre.Dubstep)] - [InlineData(Genre.Lofi, Genre.DrumAndBass)] - public void AreCompatible_IncompatiblePair_ReturnsFalse(Genre first, Genre second) + public sealed class GenreCompatibilityTests { - Assert.False(GenreCompatibility.AreCompatible(first, second)); - Assert.False(GenreCompatibility.AreCompatible(second, first)); - } + [Theory] + [InlineData(Genre.Classical, Genre.Metal)] + [InlineData(Genre.Classical, Genre.Punk)] + [InlineData(Genre.Ambient, Genre.Dubstep)] + [InlineData(Genre.Lofi, Genre.DrumAndBass)] + public void AreCompatible_IncompatiblePair_ReturnsFalse(Genre first, Genre second) + { + Assert.False(GenreCompatibility.AreCompatible(first, second)); + Assert.False(GenreCompatibility.AreCompatible(second, first)); + } - [Theory] - [InlineData(Genre.Pop, Genre.Rock)] - [InlineData(Genre.HipHop, Genre.RnB)] - [InlineData(Genre.Classical, Genre.Jazz)] - public void AreCompatible_CompatiblePair_ReturnsTrue(Genre first, Genre second) - { - Assert.True(GenreCompatibility.AreCompatible(first, second)); - } + [Theory] + [InlineData(Genre.Pop, Genre.Rock)] + [InlineData(Genre.HipHop, Genre.RnB)] + [InlineData(Genre.Classical, Genre.Jazz)] + public void AreCompatible_CompatiblePair_ReturnsTrue(Genre first, Genre second) + { + Assert.True(GenreCompatibility.AreCompatible(first, second)); + } - [Fact] - public void AreCompatible_SameGenre_ReturnsTrue() - { - Assert.True(GenreCompatibility.AreCompatible(Genre.Pop, Genre.Pop)); - } + [Fact] + public void AreCompatible_SameGenre_ReturnsTrue() + { + Assert.True(GenreCompatibility.AreCompatible(Genre.Pop, Genre.Pop)); + } - [Fact] - public void FindConflicts_NoIncompatiblePairs_ReturnsEmpty() - { - var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Rock, Genre.Indie]); + [Fact] + public void FindConflicts_NoIncompatiblePairs_ReturnsEmpty() + { + var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Rock, Genre.Indie]); - Assert.Empty(conflicts); - } + Assert.Empty(conflicts); + } - [Fact] - public void FindConflicts_IncompatiblePairPresent_ReturnsIt() - { - var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Classical, Genre.Metal]); + [Fact] + public void FindConflicts_IncompatiblePairPresent_ReturnsIt() + { + var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Classical, Genre.Metal]); - var conflict = Assert.Single(conflicts); - Assert.Equal((Genre.Classical, Genre.Metal), conflict); + var conflict = Assert.Single(conflicts); + Assert.Equal((Genre.Classical, Genre.Metal), conflict); + } } } diff --git a/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs index 7b4e0a5..78fde26 100644 --- a/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs +++ b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs @@ -1,22 +1,23 @@ using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Domain.Tests.ValueObjects; - -public sealed class TrackAudioTests +namespace Musify.Domain.Tests.ValueObjects { - [Theory] - [InlineData(ProcessingStatus.Pending, false, false, true)] - [InlineData(ProcessingStatus.Processing, false, false, true)] - [InlineData(ProcessingStatus.Completed, true, false, false)] - [InlineData(ProcessingStatus.Failed, false, true, false)] - public void ComputedFlags_ReflectTranscodeStatus( - ProcessingStatus status, bool expectedProcessed, bool expectedFailed, bool expectedInProgress) + public sealed class TrackAudioTests { - var audio = new TrackAudio { TranscodeStatus = status }; + [Theory] + [InlineData(ProcessingStatus.Pending, false, false, true)] + [InlineData(ProcessingStatus.Processing, false, false, true)] + [InlineData(ProcessingStatus.Completed, true, false, false)] + [InlineData(ProcessingStatus.Failed, false, true, false)] + public void ComputedFlags_ReflectTranscodeStatus( + ProcessingStatus status, bool expectedProcessed, bool expectedFailed, bool expectedInProgress) + { + var audio = new TrackAudio { TranscodeStatus = status }; - Assert.Equal(expectedProcessed, audio.IsProcessed); - Assert.Equal(expectedFailed, audio.IsFailed); - Assert.Equal(expectedInProgress, audio.IsInProgress); + Assert.Equal(expectedProcessed, audio.IsProcessed); + Assert.Equal(expectedFailed, audio.IsFailed); + Assert.Equal(expectedInProgress, audio.IsInProgress); + } } } diff --git a/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs index a9b0ca3..9ac53db 100644 --- a/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs +++ b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs @@ -1,19 +1,20 @@ using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Domain.Tests.ValueObjects; - -public sealed class TrackPicturesTests +namespace Musify.Domain.Tests.ValueObjects { - [Theory] - [InlineData(ProcessingStatus.Pending, false)] - [InlineData(ProcessingStatus.Processing, false)] - [InlineData(ProcessingStatus.Completed, true)] - [InlineData(ProcessingStatus.Failed, false)] - public void IsProcessed_ReflectsProcessingStatus(ProcessingStatus status, bool expected) + public sealed class TrackPicturesTests { - var pictures = new TrackPictures { ProcessingStatus = status }; + [Theory] + [InlineData(ProcessingStatus.Pending, false)] + [InlineData(ProcessingStatus.Processing, false)] + [InlineData(ProcessingStatus.Completed, true)] + [InlineData(ProcessingStatus.Failed, false)] + public void IsProcessed_ReflectsProcessingStatus(ProcessingStatus status, bool expected) + { + var pictures = new TrackPictures { ProcessingStatus = status }; - Assert.Equal(expected, pictures.IsProcessed); + Assert.Equal(expected, pictures.IsProcessed); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs index 248e062..a9ac622 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs @@ -1,22 +1,23 @@ using Musify.Infrastructure.Jobs; using Xunit; -namespace Musify.Infrastructure.Tests.Jobs; - -public sealed class RecurringJobsRegistrarTests +namespace Musify.Infrastructure.Tests.Jobs { - [Theory] - [InlineData(1, "*/1 * * * *")] - [InlineData(59, "*/1 * * * *")] - [InlineData(60, "*/1 * * * *")] - [InlineData(61, "*/2 * * * *")] - [InlineData(300, "*/5 * * * *")] - [InlineData(3599, "0 */1 * * *")] - [InlineData(3600, "0 */1 * * *")] - [InlineData(7200, "0 */2 * * *")] - [InlineData(0, "*/1 * * * *")] - public void EveryCron_RoundsUpToWholeMinutes(int seconds, string expected) + public sealed class RecurringJobsRegistrarTests { - Assert.Equal(expected, RecurringJobsRegistrar.EveryCron(seconds)); + [Theory] + [InlineData(1, "*/1 * * * *")] + [InlineData(59, "*/1 * * * *")] + [InlineData(60, "*/1 * * * *")] + [InlineData(61, "*/2 * * * *")] + [InlineData(300, "*/5 * * * *")] + [InlineData(3599, "0 */1 * * *")] + [InlineData(3600, "0 */1 * * *")] + [InlineData(7200, "0 */2 * * *")] + [InlineData(0, "*/1 * * * *")] + public void EveryCron_RoundsUpToWholeMinutes(int seconds, string expected) + { + Assert.Equal(expected, RecurringJobsRegistrar.EveryCron(seconds)); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs index d1a0705..c829886 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs @@ -6,200 +6,201 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; using Musify.Infrastructure.Tests.TestSupport; using Xunit; -namespace Musify.Infrastructure.Tests.MassTransit; - -[Collection(InfrastructureCollection.Name)] -public sealed class DeleteRoutingSlipBuilderTests(InfrastructureTestFixture fixture) +namespace Musify.Infrastructure.Tests.MassTransit { - private static CancellationToken Ct => TestContext.Current.CancellationToken; - - [Fact] - public async Task BuildAlbum_WithProcessedPictures_RemovesEveryPictureBeforeDeletingTheRow() + [Collection(InfrastructureCollection.Name)] + public sealed class DeleteRoutingSlipBuilderTests(InfrastructureTestFixture fixture) { - var album = await SeedAlbumAsync(new EntityPictures + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task BuildAlbum_WithProcessedPictures_RemovesEveryPictureBeforeDeletingTheRow() { - OriginalName = "original.webp", - SmallName = "small.webp", - MediumName = "medium.webp", - LargeName = "large.webp" - }); - await using var database = fixture.CreateDatabase(); - - var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build(); - - Assert.Equal( - [ - ActivityNames.MarkAlbumAsRemoving, - ActivityNames.RemoveAlbumOriginalPicture, - ActivityNames.RemoveAlbumSmallPicture, - ActivityNames.RemoveAlbumMediumPicture, - ActivityNames.RemoveAlbumLargePicture, - ActivityNames.DeleteAlbumFromDb, - ], - slip.Itinerary.Select(activity => activity.Name)); - } - - [Fact] - public async Task BuildAlbum_WithPendingPicture_OnlyRemovesTheOriginal() - { - var album = await SeedAlbumAsync(EntityPictures.Pending("original.webp")); - await using var database = fixture.CreateDatabase(); - - var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build(); - - Assert.Equal( - [ActivityNames.MarkAlbumAsRemoving, ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.DeleteAlbumFromDb], - slip.Itinerary.Select(activity => activity.Name)); - } - - [Fact] - public async Task BuildAlbum_MissingAlbum_OnlyMarksAndDeletes() - { - await using var database = fixture.CreateDatabase(); - - var slip = (await CreateBuilder(database).BuildAlbumAsync(Guid.NewGuid(), 1, null, Ct)).Build(); - - Assert.Equal( - [ActivityNames.MarkAlbumAsRemoving, ActivityNames.DeleteAlbumFromDb], - slip.Itinerary.Select(activity => activity.Name)); - } - - [Fact] - public async Task BuildPlayList_WithoutPictures_OnlyMarksAndDeletes() - { - var playList = await SeedPlayListAsync(pictures: null); - await using var database = fixture.CreateDatabase(); - - var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build(); - - Assert.Equal( - [ActivityNames.MarkPlayListAsRemoving, ActivityNames.DeletePlayListFromDb], - slip.Itinerary.Select(activity => activity.Name)); - } - - [Fact] - public async Task BuildPlayList_WithPictures_RemovesThemBetweenMarkAndDelete() - { - var playList = await SeedPlayListAsync(new EntityPictures { OriginalName = "original.webp", SmallName = "small.webp" }); - await using var database = fixture.CreateDatabase(); - - var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build(); - - Assert.Equal( - [ - ActivityNames.MarkPlayListAsRemoving, - ActivityNames.RemovePlayListOriginalPicture, - ActivityNames.RemovePlayListSmallPicture, - ActivityNames.DeletePlayListFromDb, - ], - slip.Itinerary.Select(activity => activity.Name)); - } - - [Fact] - public async Task BuildTrack_Unprocessed_NeverRemovesTheSharedPresetPictures() - { - var track = await SeedTrackAsync(new TrackPictures { OriginalName = "original.webp", SmallName = "preset.webp" }, new TrackAudio { OriginalName = "audio.mp3" }); - await using var database = fixture.CreateDatabase(); - - var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build(); - - Assert.Equal( - [ - ActivityNames.MarkTrackAsRemoving, - ActivityNames.RemoveTrackOriginalPicture, - ActivityNames.RemoveTrackOriginalAudio, - ActivityNames.DeleteTrackFromDb, - ], - slip.Itinerary.Select(activity => activity.Name)); - } - - [Fact] - public async Task BuildTrack_Processed_RemovesResizedPicturesAndProcessedAudio() - { - var track = await SeedTrackAsync( - new TrackPictures + var album = await SeedAlbumAsync(new EntityPictures { OriginalName = "original.webp", - SmallName = "s.webp", - MediumName = "m.webp", - LargeName = "l.webp", - ProcessingStatus = ProcessingStatus.Completed - }, - new TrackAudio { OriginalName = "audio.mp3", FolderName = "folder", TranscodeStatus = ProcessingStatus.Completed }); - await using var database = fixture.CreateDatabase(); + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp" + }); + await using var database = fixture.CreateDatabase(); - var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build(); + var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build(); - Assert.Equal( - [ - ActivityNames.MarkTrackAsRemoving, - ActivityNames.RemoveTrackOriginalPicture, - ActivityNames.RemoveTrackSmallPicture, - ActivityNames.RemoveTrackMediumPicture, - ActivityNames.RemoveTrackLargePicture, - ActivityNames.RemoveTrackOriginalAudio, - ActivityNames.RemoveTrackProcessedAudio, - ActivityNames.DeleteTrackFromDb, - ], - slip.Itinerary.Select(activity => activity.Name)); - } + Assert.Equal( + [ + ActivityNames.MarkAlbumAsRemoving, + ActivityNames.RemoveAlbumOriginalPicture, + ActivityNames.RemoveAlbumSmallPicture, + ActivityNames.RemoveAlbumMediumPicture, + ActivityNames.RemoveAlbumLargePicture, + ActivityNames.DeleteAlbumFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } - private static DeleteRoutingSlipBuilder CreateBuilder(Musify.Application.Contracts.IDatabase database) => - new( - database, - new TrackConfiguration(), - new AlbumConfiguration(), - new PlayListConfiguration(), - new ApplicationStorageConfiguration { Bucket = "bucket" }); - - private async Task SeedUserAsync() - { - await using var database = fixture.CreateDatabase(); - var name = $"delete-{Guid.NewGuid():N}"[..30]; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; - await database.Users.AddAsync(user, Ct); - await database.SaveChangesAsync(Ct); - return user; - } - - private async Task SeedAlbumAsync(EntityPictures? pictures) - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var title = $"album-{Guid.NewGuid():N}"; - var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; - await database.Albums.AddAsync(album, Ct); - await database.SaveChangesAsync(Ct); - return album; - } - - private async Task SeedPlayListAsync(EntityPictures? pictures) - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var name = $"pl-{Guid.NewGuid():N}"[..30]; - var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; - await database.PlayLists.AddAsync(playList, Ct); - await database.SaveChangesAsync(Ct); - return playList; - } - - private async Task SeedTrackAsync(TrackPictures pictures, TrackAudio audio) - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var title = $"track-{Guid.NewGuid():N}"; - var track = new Track + [Fact] + public async Task BuildAlbum_WithPendingPicture_OnlyRemovesTheOriginal() { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - DurationSeconds = 10, - OwnerUserId = user.Id, - Pictures = pictures, - Audio = audio - }; - await database.Tracks.AddAsync(track, Ct); - await database.SaveChangesAsync(Ct); - return track; + var album = await SeedAlbumAsync(EntityPictures.Pending("original.webp")); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ActivityNames.MarkAlbumAsRemoving, ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.DeleteAlbumFromDb], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildAlbum_MissingAlbum_OnlyMarksAndDeletes() + { + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildAlbumAsync(Guid.NewGuid(), 1, null, Ct)).Build(); + + Assert.Equal( + [ActivityNames.MarkAlbumAsRemoving, ActivityNames.DeleteAlbumFromDb], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildPlayList_WithoutPictures_OnlyMarksAndDeletes() + { + var playList = await SeedPlayListAsync(pictures: null); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ActivityNames.MarkPlayListAsRemoving, ActivityNames.DeletePlayListFromDb], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildPlayList_WithPictures_RemovesThemBetweenMarkAndDelete() + { + var playList = await SeedPlayListAsync(new EntityPictures { OriginalName = "original.webp", SmallName = "small.webp" }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkPlayListAsRemoving, + ActivityNames.RemovePlayListOriginalPicture, + ActivityNames.RemovePlayListSmallPicture, + ActivityNames.DeletePlayListFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildTrack_Unprocessed_NeverRemovesTheSharedPresetPictures() + { + var track = await SeedTrackAsync(new TrackPictures { OriginalName = "original.webp", SmallName = "preset.webp" }, new TrackAudio { OriginalName = "audio.mp3" }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkTrackAsRemoving, + ActivityNames.RemoveTrackOriginalPicture, + ActivityNames.RemoveTrackOriginalAudio, + ActivityNames.DeleteTrackFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildTrack_Processed_RemovesResizedPicturesAndProcessedAudio() + { + var track = await SeedTrackAsync( + new TrackPictures + { + OriginalName = "original.webp", + SmallName = "s.webp", + MediumName = "m.webp", + LargeName = "l.webp", + ProcessingStatus = ProcessingStatus.Completed + }, + new TrackAudio { OriginalName = "audio.mp3", FolderName = "folder", TranscodeStatus = ProcessingStatus.Completed }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkTrackAsRemoving, + ActivityNames.RemoveTrackOriginalPicture, + ActivityNames.RemoveTrackSmallPicture, + ActivityNames.RemoveTrackMediumPicture, + ActivityNames.RemoveTrackLargePicture, + ActivityNames.RemoveTrackOriginalAudio, + ActivityNames.RemoveTrackProcessedAudio, + ActivityNames.DeleteTrackFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + private static DeleteRoutingSlipBuilder CreateBuilder(Musify.Application.Contracts.IDatabase database) => + new( + database, + new TrackConfiguration(), + new AlbumConfiguration(), + new PlayListConfiguration(), + new ApplicationStorageConfiguration { Bucket = "bucket" }); + + private async Task SeedUserAsync() + { + await using var database = fixture.CreateDatabase(); + var name = $"delete-{Guid.NewGuid():N}"[..30]; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, Ct); + await database.SaveChangesAsync(Ct); + return user; + } + + private async Task SeedAlbumAsync(EntityPictures? pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"album-{Guid.NewGuid():N}"; + var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.Albums.AddAsync(album, Ct); + await database.SaveChangesAsync(Ct); + return album; + } + + private async Task SeedPlayListAsync(EntityPictures? pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var name = $"pl-{Guid.NewGuid():N}"[..30]; + var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.PlayLists.AddAsync(playList, Ct); + await database.SaveChangesAsync(Ct); + return playList; + } + + private async Task SeedTrackAsync(TrackPictures pictures, TrackAudio audio) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"track-{Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 10, + OwnerUserId = user.Id, + Pictures = pictures, + Audio = audio + }; + await database.Tracks.AddAsync(track, Ct); + await database.SaveChangesAsync(Ct); + return track; + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs index 2d73fa0..8c3930f 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs @@ -9,177 +9,178 @@ using Musify.Infrastructure.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Infrastructure.Tests.MassTransit; - -[Collection(InfrastructureCollection.Name)] -public sealed class LifeCycleActivityTests(InfrastructureTestFixture fixture) +namespace Musify.Infrastructure.Tests.MassTransit { - private static CancellationToken Ct => TestContext.Current.CancellationToken; - - [Fact] - public async Task MarkTrack_ExistingTrack_SetsTheStatus() + [Collection(InfrastructureCollection.Name)] + public sealed class LifeCycleActivityTests(InfrastructureTestFixture fixture) { - var track = await SeedTrackAsync(); - await using var database = fixture.CreateDatabase(); - var activity = new MarkTrackLifeCycleActivity(database, NullLogger.Instance); - var context = ExecuteContextFor(new MarkLifeCycleArguments(track.Id, LifeCycleStatus.Removing)); + private static CancellationToken Ct => TestContext.Current.CancellationToken; - await activity.Execute(context); - - context.Received(1).Completed(); - await using var verifier = fixture.CreateDatabase(); - var stored = await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct); - Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); - } - - [Fact] - public async Task MarkAlbum_ExistingAlbum_SetsTheStatus() - { - var album = await SeedAlbumAsync(); - await using var database = fixture.CreateDatabase(); - var activity = new MarkAlbumLifeCycleActivity(database, NullLogger.Instance); - - await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(album.Id, LifeCycleStatus.Failed))); - - await using var verifier = fixture.CreateDatabase(); - Assert.Equal(LifeCycleStatus.Failed, (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).LifeCycleStatus); - } - - [Fact] - public async Task MarkPlayList_ExistingPlayList_SetsTheStatus() - { - var playList = await SeedPlayListAsync(); - await using var database = fixture.CreateDatabase(); - var activity = new MarkPlayListLifeCycleActivity(database, NullLogger.Instance); - - await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(playList.Id, LifeCycleStatus.Removing))); - - await using var verifier = fixture.CreateDatabase(); - Assert.Equal(LifeCycleStatus.Removing, (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).LifeCycleStatus); - } - - [Fact] - public async Task Mark_MissingEntity_CompletesWithoutFailing() - { - await using var database = fixture.CreateDatabase(); - var activity = new MarkTrackLifeCycleActivity(database, NullLogger.Instance); - var context = ExecuteContextFor(new MarkLifeCycleArguments(Guid.NewGuid(), LifeCycleStatus.Failed)); - - await activity.Execute(context); - - context.Received(1).Completed(); - } - - [Fact] - public async Task DeleteTrack_ExistingTrack_RemovesTheRow() - { - var track = await SeedTrackAsync(); - await using var database = fixture.CreateDatabase(); - var activity = new DeleteTrackActivity(database, NullLogger.Instance); - var context = ExecuteContextFor(new DeleteEntityArguments(track.Id)); - - await activity.Execute(context); - - context.Received(1).Completed(); - await using var verifier = fixture.CreateDatabase(); - Assert.False(await verifier.Tracks.AnyAsync(t => t.Id == track.Id, Ct)); - } - - [Fact] - public async Task DeleteAlbum_ExistingAlbum_RemovesTheRow() - { - var album = await SeedAlbumAsync(); - await using var database = fixture.CreateDatabase(); - var activity = new DeleteAlbumActivity(database, NullLogger.Instance); - - await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(album.Id))); - - await using var verifier = fixture.CreateDatabase(); - Assert.False(await verifier.Albums.AnyAsync(a => a.Id == album.Id, Ct)); - } - - [Fact] - public async Task DeletePlayList_ExistingPlayList_RemovesTheRow() - { - var playList = await SeedPlayListAsync(); - await using var database = fixture.CreateDatabase(); - var activity = new DeletePlayListActivity(database, NullLogger.Instance); - - await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(playList.Id))); - - await using var verifier = fixture.CreateDatabase(); - Assert.False(await verifier.PlayLists.AnyAsync(p => p.Id == playList.Id, Ct)); - } - - [Fact] - public async Task Delete_MissingEntity_CompletesWithoutFailing() - { - await using var database = fixture.CreateDatabase(); - var activity = new DeleteTrackActivity(database, NullLogger.Instance); - var context = ExecuteContextFor(new DeleteEntityArguments(Guid.NewGuid())); - - await activity.Execute(context); - - context.Received(1).Completed(); - } - - private static ExecuteContext ExecuteContextFor(TArguments arguments) - where TArguments : class - { - var context = Substitute.For>(); - context.Arguments.Returns(arguments); - context.CancellationToken.Returns(Ct); - return context; - } - - private async Task SeedUserAsync() - { - await using var database = fixture.CreateDatabase(); - var name = $"lifecycle-{Guid.NewGuid():N}"[..30]; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; - await database.Users.AddAsync(user, Ct); - await database.SaveChangesAsync(Ct); - return user; - } - - private async Task SeedTrackAsync() - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var title = $"track-{Guid.NewGuid():N}"; - var track = new Track + [Fact] + public async Task MarkTrack_ExistingTrack_SetsTheStatus() { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - DurationSeconds = 10, - OwnerUserId = user.Id, - Pictures = new TrackPictures(), - Audio = new TrackAudio() - }; - await database.Tracks.AddAsync(track, Ct); - await database.SaveChangesAsync(Ct); - return track; - } + var track = await SeedTrackAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new MarkTrackLifeCycleActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new MarkLifeCycleArguments(track.Id, LifeCycleStatus.Removing)); - private async Task SeedAlbumAsync() - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var title = $"album-{Guid.NewGuid():N}"; - var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id }; - await database.Albums.AddAsync(album, Ct); - await database.SaveChangesAsync(Ct); - return album; - } + await activity.Execute(context); - private async Task SeedPlayListAsync() - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var name = $"pl-{Guid.NewGuid():N}"[..30]; - var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id }; - await database.PlayLists.AddAsync(playList, Ct); - await database.SaveChangesAsync(Ct); - return playList; + context.Received(1).Completed(); + await using var verifier = fixture.CreateDatabase(); + var stored = await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct); + Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); + } + + [Fact] + public async Task MarkAlbum_ExistingAlbum_SetsTheStatus() + { + var album = await SeedAlbumAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new MarkAlbumLifeCycleActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(album.Id, LifeCycleStatus.Failed))); + + await using var verifier = fixture.CreateDatabase(); + Assert.Equal(LifeCycleStatus.Failed, (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).LifeCycleStatus); + } + + [Fact] + public async Task MarkPlayList_ExistingPlayList_SetsTheStatus() + { + var playList = await SeedPlayListAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new MarkPlayListLifeCycleActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(playList.Id, LifeCycleStatus.Removing))); + + await using var verifier = fixture.CreateDatabase(); + Assert.Equal(LifeCycleStatus.Removing, (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).LifeCycleStatus); + } + + [Fact] + public async Task Mark_MissingEntity_CompletesWithoutFailing() + { + await using var database = fixture.CreateDatabase(); + var activity = new MarkTrackLifeCycleActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new MarkLifeCycleArguments(Guid.NewGuid(), LifeCycleStatus.Failed)); + + await activity.Execute(context); + + context.Received(1).Completed(); + } + + [Fact] + public async Task DeleteTrack_ExistingTrack_RemovesTheRow() + { + var track = await SeedTrackAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new DeleteTrackActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new DeleteEntityArguments(track.Id)); + + await activity.Execute(context); + + context.Received(1).Completed(); + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.Tracks.AnyAsync(t => t.Id == track.Id, Ct)); + } + + [Fact] + public async Task DeleteAlbum_ExistingAlbum_RemovesTheRow() + { + var album = await SeedAlbumAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new DeleteAlbumActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(album.Id))); + + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.Albums.AnyAsync(a => a.Id == album.Id, Ct)); + } + + [Fact] + public async Task DeletePlayList_ExistingPlayList_RemovesTheRow() + { + var playList = await SeedPlayListAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new DeletePlayListActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(playList.Id))); + + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.PlayLists.AnyAsync(p => p.Id == playList.Id, Ct)); + } + + [Fact] + public async Task Delete_MissingEntity_CompletesWithoutFailing() + { + await using var database = fixture.CreateDatabase(); + var activity = new DeleteTrackActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new DeleteEntityArguments(Guid.NewGuid())); + + await activity.Execute(context); + + context.Received(1).Completed(); + } + + private static ExecuteContext ExecuteContextFor(TArguments arguments) + where TArguments : class + { + var context = Substitute.For>(); + context.Arguments.Returns(arguments); + context.CancellationToken.Returns(Ct); + return context; + } + + private async Task SeedUserAsync() + { + await using var database = fixture.CreateDatabase(); + var name = $"lifecycle-{Guid.NewGuid():N}"[..30]; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, Ct); + await database.SaveChangesAsync(Ct); + return user; + } + + private async Task SeedTrackAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"track-{Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 10, + OwnerUserId = user.Id, + Pictures = new TrackPictures(), + Audio = new TrackAudio() + }; + await database.Tracks.AddAsync(track, Ct); + await database.SaveChangesAsync(Ct); + return track; + } + + private async Task SeedAlbumAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"album-{Guid.NewGuid():N}"; + var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id }; + await database.Albums.AddAsync(album, Ct); + await database.SaveChangesAsync(Ct); + return album; + } + + private async Task SeedPlayListAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var name = $"pl-{Guid.NewGuid():N}"[..30]; + var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id }; + await database.PlayLists.AddAsync(playList, Ct); + await database.SaveChangesAsync(Ct); + return playList; + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs index 590b01c..975319c 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs @@ -7,102 +7,103 @@ using Musify.Infrastructure.MassTransit.Consumers; using Musify.Infrastructure.MassTransit.RoutingSlip; using Xunit; -namespace Musify.Infrastructure.Tests.MassTransit; - -public sealed class ProcessingSlipFaultConsumerTests +namespace Musify.Infrastructure.Tests.MassTransit { - private static readonly Guid SubjectId = Guid.NewGuid(); - - private static CancellationToken Ct => TestContext.Current.CancellationToken; - - [Fact] - public async Task Consume_TrackPicture_PublishesTrackPictureProcessingFailed() => - await AssertPublishedAsync( - RoutingSlipVariableNames.ProcessKinds.TrackPicture, message => message.TrackId == SubjectId); - - [Fact] - public async Task Consume_TrackAudio_PublishesTrackAudioProcessingFailed() => - await AssertPublishedAsync( - RoutingSlipVariableNames.ProcessKinds.TrackAudio, message => message.TrackId == SubjectId); - - [Fact] - public async Task Consume_PlayListPicture_PublishesPlayListPictureProcessingFailed() => - await AssertPublishedAsync( - RoutingSlipVariableNames.ProcessKinds.PlayListPicture, message => message.PlayListId == SubjectId); - - [Fact] - public async Task Consume_AlbumPicture_PublishesAlbumPictureProcessingFailed() => - await AssertPublishedAsync( - RoutingSlipVariableNames.ProcessKinds.AlbumPicture, message => message.AlbumId == SubjectId); - - [Fact] - public async Task Consume_TrackCreation_PublishesTrackProcessingFailedWithKeys() => - await AssertPublishedAsync( - RoutingSlipVariableNames.ProcessKinds.TrackCreation, - message => message is { Bucket: "bucket", PictureKey: "picture-key", AudioKey: "audio-key" } && message.TrackId == SubjectId); - - [Fact] - public async Task Consume_AlbumCreation_PublishesAlbumProcessingFailedWithKeys() => - await AssertPublishedAsync( - RoutingSlipVariableNames.ProcessKinds.AlbumCreation, - message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.AlbumId == SubjectId); - - [Fact] - public async Task Consume_PlayListCreation_PublishesPlayListProcessingFailedWithKeys() => - await AssertPublishedAsync( - RoutingSlipVariableNames.ProcessKinds.PlayListCreation, - message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.PlayListId == SubjectId); - - [Fact] - public async Task Consume_UnknownKind_PublishesNothing() + public sealed class ProcessingSlipFaultConsumerTests { - await using var provider = CreateProvider(); - var harness = provider.GetRequiredService(); - await harness.Start(); + private static readonly Guid SubjectId = Guid.NewGuid(); - await harness.Bus.Publish(new { Variables = Variables("Something else") }, Ct); + private static CancellationToken Ct => TestContext.Current.CancellationToken; - Assert.True(await harness.Consumed.Any(Ct)); - Assert.False(await harness.Published.Any(Ct)); - Assert.False(await harness.Published.Any(Ct)); + [Fact] + public async Task Consume_TrackPicture_PublishesTrackPictureProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.TrackPicture, message => message.TrackId == SubjectId); + + [Fact] + public async Task Consume_TrackAudio_PublishesTrackAudioProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.TrackAudio, message => message.TrackId == SubjectId); + + [Fact] + public async Task Consume_PlayListPicture_PublishesPlayListPictureProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.PlayListPicture, message => message.PlayListId == SubjectId); + + [Fact] + public async Task Consume_AlbumPicture_PublishesAlbumPictureProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.AlbumPicture, message => message.AlbumId == SubjectId); + + [Fact] + public async Task Consume_TrackCreation_PublishesTrackProcessingFailedWithKeys() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.TrackCreation, + message => message is { Bucket: "bucket", PictureKey: "picture-key", AudioKey: "audio-key" } && message.TrackId == SubjectId); + + [Fact] + public async Task Consume_AlbumCreation_PublishesAlbumProcessingFailedWithKeys() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.AlbumCreation, + message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.AlbumId == SubjectId); + + [Fact] + public async Task Consume_PlayListCreation_PublishesPlayListProcessingFailedWithKeys() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.PlayListCreation, + message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.PlayListId == SubjectId); + + [Fact] + public async Task Consume_UnknownKind_PublishesNothing() + { + await using var provider = CreateProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + await harness.Bus.Publish(new { Variables = Variables("Something else") }, Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Consume_WithoutProcessKind_PublishesNothing() + { + await using var provider = CreateProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + await harness.Bus.Publish(new { Variables = new Dictionary() }, Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + } + + private static async Task AssertPublishedAsync(string processKind, Func matches) + where TEvent : class + { + await using var provider = CreateProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + await harness.Bus.Publish(new { Variables = Variables(processKind) }, Ct); + + Assert.True(await harness.Published.Any(published => matches(published.Context.Message), Ct)); + } + + private static Dictionary Variables(string processKind) => new() + { + [RoutingSlipVariableNames.Workflow.ProcessKind] = processKind, + [RoutingSlipVariableNames.Workflow.SubjectId] = SubjectId, + [RoutingSlipVariableNames.Workflow.Bucket] = "bucket", + [RoutingSlipVariableNames.Workflow.PictureKey] = "picture-key", + [RoutingSlipVariableNames.Workflow.AudioKey] = "audio-key", + }; + + private static ServiceProvider CreateProvider() => + new ServiceCollection() + .AddMassTransitTestHarness(bus => bus.AddConsumer()) + .BuildServiceProvider(true); } - - [Fact] - public async Task Consume_WithoutProcessKind_PublishesNothing() - { - await using var provider = CreateProvider(); - var harness = provider.GetRequiredService(); - await harness.Start(); - - await harness.Bus.Publish(new { Variables = new Dictionary() }, Ct); - - Assert.True(await harness.Consumed.Any(Ct)); - Assert.False(await harness.Published.Any(Ct)); - } - - private static async Task AssertPublishedAsync(string processKind, Func matches) - where TEvent : class - { - await using var provider = CreateProvider(); - var harness = provider.GetRequiredService(); - await harness.Start(); - - await harness.Bus.Publish(new { Variables = Variables(processKind) }, Ct); - - Assert.True(await harness.Published.Any(published => matches(published.Context.Message), Ct)); - } - - private static Dictionary Variables(string processKind) => new() - { - [RoutingSlipVariableNames.Workflow.ProcessKind] = processKind, - [RoutingSlipVariableNames.Workflow.SubjectId] = SubjectId, - [RoutingSlipVariableNames.Workflow.Bucket] = "bucket", - [RoutingSlipVariableNames.Workflow.PictureKey] = "picture-key", - [RoutingSlipVariableNames.Workflow.AudioKey] = "audio-key", - }; - - private static ServiceProvider CreateProvider() => - new ServiceCollection() - .AddMassTransitTestHarness(bus => bus.AddConsumer()) - .BuildServiceProvider(true); } diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs index a86a199..25ff4f9 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs @@ -6,202 +6,203 @@ using Musify.Application.Shared; using Musify.Infrastructure.MassTransit.Sagas; using Xunit; -namespace Musify.Infrastructure.Tests.MassTransit; - -public sealed class ProcessingStateMachineTests +namespace Musify.Infrastructure.Tests.MassTransit { - private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3)); - - private static CancellationToken Ct => TestContext.Current.CancellationToken; - - // Track - - [Fact] - public async Task Track_PictureAndAudioProcessed_CompletesTheSaga() + public sealed class ProcessingStateMachineTests { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - var saga = provider.GetRequiredService>(); - var id = Guid.NewGuid(); + private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3)); - await harness.Bus.Publish(TrackStarted(id), Ct); - Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + private static CancellationToken Ct => TestContext.Current.CancellationToken; - await harness.Bus.Publish(new TrackPictureProcessed(id), Ct); - await harness.Bus.Publish(new TrackAudioProcessed(id), Ct); + // Track - Assert.Null(await saga.NotExists(id)); - Assert.False(await harness.Published.Any(Ct)); + [Fact] + public async Task Track_PictureAndAudioProcessed_CompletesTheSaga() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(TrackStarted(id), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new TrackPictureProcessed(id), Ct); + await harness.Bus.Publish(new TrackAudioProcessed(id), Ct); + + Assert.Null(await saga.NotExists(id)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Track_PictureFailed_PublishesProcessingFailedAndMovesToFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(TrackStarted(id), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new TrackPictureProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any( + published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture", AudioKey: "audio" } + && published.Context.Message.TrackId == id, Ct)); + } + + [Fact] + public async Task Track_AudioFailed_PublishesProcessingFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(TrackStarted(id), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Track_EventForMissingInstance_IsDiscarded() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new TrackPictureProcessed(id), Ct); + await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + Assert.False(await harness.Published.Any(Ct)); + } + + // Album + + [Fact] + public async Task Album_PictureProcessed_CompletesTheSaga() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new AlbumPictureProcessed(id), Ct); + + Assert.Null(await saga.NotExists(id)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Album_PictureFailed_PublishesProcessingFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new AlbumPictureProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any( + published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" } + && published.Context.Message.AlbumId == id, Ct)); + } + + [Fact] + public async Task Album_EventForMissingInstance_IsDiscarded() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + await harness.Bus.Publish(new AlbumPictureProcessingFailed(Guid.NewGuid()), Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + } + + // PlayList + + [Fact] + public async Task PlayList_PictureProcessed_CompletesTheSaga() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new PlayListPictureProcessed(id), Ct); + + Assert.Null(await saga.NotExists(id)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task PlayList_PictureFailed_PublishesProcessingFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new PlayListPictureProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any( + published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" } + && published.Context.Message.PlayListId == id, Ct)); + } + + [Fact] + public async Task PlayList_EventForMissingInstance_IsDiscarded() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + await harness.Bus.Publish(new PlayListPictureProcessingFailed(Guid.NewGuid()), Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + } + + private static CreateTrackResourcesEvent TrackStarted(Guid id) => + new(id, Guid.NewGuid(), Guid.NewGuid(), "bucket", "picture-source", "picture", "audio-source", "audio", "audio-folder", Sizes); + + private static async Task StartAsync(ServiceProvider provider) + { + var harness = provider.GetRequiredService(); + await harness.Start(); + return harness; + } + + private static ServiceProvider CreateProvider() + where TMachine : class, SagaStateMachine + where TState : class, SagaStateMachineInstance => + new ServiceCollection() + .AddMassTransitTestHarness(bus => bus.AddSagaStateMachine().InMemoryRepository()) + .BuildServiceProvider(true); } - - [Fact] - public async Task Track_PictureFailed_PublishesProcessingFailedAndMovesToFailed() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - var saga = provider.GetRequiredService>(); - var id = Guid.NewGuid(); - - await harness.Bus.Publish(TrackStarted(id), Ct); - Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); - - await harness.Bus.Publish(new TrackPictureProcessingFailed(id), Ct); - - Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); - Assert.True(await harness.Published.Any( - published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture", AudioKey: "audio" } - && published.Context.Message.TrackId == id, Ct)); - } - - [Fact] - public async Task Track_AudioFailed_PublishesProcessingFailed() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - var saga = provider.GetRequiredService>(); - var id = Guid.NewGuid(); - - await harness.Bus.Publish(TrackStarted(id), Ct); - Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); - - await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct); - - Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); - Assert.True(await harness.Published.Any(Ct)); - } - - [Fact] - public async Task Track_EventForMissingInstance_IsDiscarded() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - var id = Guid.NewGuid(); - - await harness.Bus.Publish(new TrackPictureProcessed(id), Ct); - await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct); - - Assert.True(await harness.Consumed.Any(Ct)); - Assert.False(await harness.Published.Any>(Ct)); - Assert.False(await harness.Published.Any>(Ct)); - Assert.False(await harness.Published.Any(Ct)); - } - - // Album - - [Fact] - public async Task Album_PictureProcessed_CompletesTheSaga() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - var saga = provider.GetRequiredService>(); - var id = Guid.NewGuid(); - - await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); - Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); - - await harness.Bus.Publish(new AlbumPictureProcessed(id), Ct); - - Assert.Null(await saga.NotExists(id)); - Assert.False(await harness.Published.Any(Ct)); - } - - [Fact] - public async Task Album_PictureFailed_PublishesProcessingFailed() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - var saga = provider.GetRequiredService>(); - var id = Guid.NewGuid(); - - await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); - Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); - - await harness.Bus.Publish(new AlbumPictureProcessingFailed(id), Ct); - - Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); - Assert.True(await harness.Published.Any( - published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" } - && published.Context.Message.AlbumId == id, Ct)); - } - - [Fact] - public async Task Album_EventForMissingInstance_IsDiscarded() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - - await harness.Bus.Publish(new AlbumPictureProcessingFailed(Guid.NewGuid()), Ct); - - Assert.True(await harness.Consumed.Any(Ct)); - Assert.False(await harness.Published.Any(Ct)); - Assert.False(await harness.Published.Any>(Ct)); - } - - // PlayList - - [Fact] - public async Task PlayList_PictureProcessed_CompletesTheSaga() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - var saga = provider.GetRequiredService>(); - var id = Guid.NewGuid(); - - await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); - Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); - - await harness.Bus.Publish(new PlayListPictureProcessed(id), Ct); - - Assert.Null(await saga.NotExists(id)); - Assert.False(await harness.Published.Any(Ct)); - } - - [Fact] - public async Task PlayList_PictureFailed_PublishesProcessingFailed() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - var saga = provider.GetRequiredService>(); - var id = Guid.NewGuid(); - - await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); - Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); - - await harness.Bus.Publish(new PlayListPictureProcessingFailed(id), Ct); - - Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); - Assert.True(await harness.Published.Any( - published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" } - && published.Context.Message.PlayListId == id, Ct)); - } - - [Fact] - public async Task PlayList_EventForMissingInstance_IsDiscarded() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - - await harness.Bus.Publish(new PlayListPictureProcessingFailed(Guid.NewGuid()), Ct); - - Assert.True(await harness.Consumed.Any(Ct)); - Assert.False(await harness.Published.Any(Ct)); - Assert.False(await harness.Published.Any>(Ct)); - } - - private static CreateTrackResourcesEvent TrackStarted(Guid id) => - new(id, Guid.NewGuid(), Guid.NewGuid(), "bucket", "picture-source", "picture", "audio-source", "audio", "audio-folder", Sizes); - - private static async Task StartAsync(ServiceProvider provider) - { - var harness = provider.GetRequiredService(); - await harness.Start(); - return harness; - } - - private static ServiceProvider CreateProvider() - where TMachine : class, SagaStateMachine - where TState : class, SagaStateMachineInstance => - new ServiceCollection() - .AddMassTransitTestHarness(bus => bus.AddSagaStateMachine().InMemoryRepository()) - .BuildServiceProvider(true); } diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs index b35c96b..de09a5b 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs @@ -7,123 +7,124 @@ using Musify.Infrastructure.MassTransit.RoutingSlip; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; using Xunit; -namespace Musify.Infrastructure.Tests.MassTransit; - -public sealed class RoutingSlipBuilderTests +namespace Musify.Infrastructure.Tests.MassTransit { - private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3)); - - private static readonly Guid SubjectId = Guid.NewGuid(); - private static readonly Guid IntentId = Guid.NewGuid(); - private static readonly Guid CorrelationId = Guid.NewGuid(); - - [Fact] - public void Create_AlwaysSubscribesTheCleanUpConsumerAndCarriesTheCorrelationId() + public sealed class RoutingSlipBuilderTests { - var slip = RoutingSlips.Create(CorrelationId).Build(); + private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3)); - Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); - var subscription = Assert.Single(slip.Subscriptions); - Assert.Equal(RoutingSlips.ConsumerUri(RoutingSlipCleanUpConsumer.QueueName), subscription.Address); - Assert.Equal(RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted, subscription.Events); - } + private static readonly Guid SubjectId = Guid.NewGuid(); + private static readonly Guid IntentId = Guid.NewGuid(); + private static readonly Guid CorrelationId = Guid.NewGuid(); - [Fact] - public void Create_WithoutCorrelationId_UsesEmptyGuid() - { - var slip = RoutingSlips.Create(null).Build(); + [Fact] + public void Create_AlwaysSubscribesTheCleanUpConsumerAndCarriesTheCorrelationId() + { + var slip = RoutingSlips.Create(CorrelationId).Build(); - Assert.Equal(Guid.Empty, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); - } + Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + var subscription = Assert.Single(slip.Subscriptions); + Assert.Equal(RoutingSlips.ConsumerUri(RoutingSlipCleanUpConsumer.QueueName), subscription.Address); + Assert.Equal(RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted, subscription.Events); + } - [Fact] - public void TrackFaults_AddsSubjectProcessKindAndFaultSubscription() - { - var slip = RoutingSlips.Create(null).TrackFaults(SubjectId, "Kind").Build(); + [Fact] + public void Create_WithoutCorrelationId_UsesEmptyGuid() + { + var slip = RoutingSlips.Create(null).Build(); - Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); - Assert.Equal("Kind", slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); - Assert.Contains(slip.Subscriptions, subscription => - subscription.Address == RoutingSlips.ConsumerUri(ProcessingSlipFaultConsumer.QueueName) - && subscription.Events == RoutingSlipEvents.Faulted); - } + Assert.Equal(Guid.Empty, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + } - [Fact] - public void PictureSource_CreateAlbum_CopiesConsumesAndPublishesInOrder() - { - var message = new CreateAlbumResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + [Fact] + public void TrackFaults_AddsSubjectProcessKindAndFaultSubscription() + { + var slip = RoutingSlips.Create(null).TrackFaults(SubjectId, "Kind").Build(); - var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); + Assert.Equal("Kind", slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); + Assert.Contains(slip.Subscriptions, subscription => + subscription.Address == RoutingSlips.ConsumerUri(ProcessingSlipFaultConsumer.QueueName) + && subscription.Events == RoutingSlipEvents.Faulted); + } - AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumCreation, ActivityNames.PublishAlbumPictureProcessingEvent); - } + [Fact] + public void PictureSource_CreateAlbum_CopiesConsumesAndPublishesInOrder() + { + var message = new CreateAlbumResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); - [Fact] - public void PictureSource_UpdateAlbum_UsesTheAlbumPictureProcessKind() - { - var message = new UpdateAlbumPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); - var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumCreation, ActivityNames.PublishAlbumPictureProcessingEvent); + } - AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumPicture, ActivityNames.PublishAlbumPictureProcessingEvent); - } + [Fact] + public void PictureSource_UpdateAlbum_UsesTheAlbumPictureProcessKind() + { + var message = new UpdateAlbumPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); - [Fact] - public void PictureSource_CreatePlayList_CopiesConsumesAndPublishesInOrder() - { - var message = new CreatePlayListResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); - var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumPicture, ActivityNames.PublishAlbumPictureProcessingEvent); + } - AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListCreation, ActivityNames.PublishPlayListPictureProcessingEvent); - } + [Fact] + public void PictureSource_CreatePlayList_CopiesConsumesAndPublishesInOrder() + { + var message = new CreatePlayListResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); - [Fact] - public void PictureSource_UpdatePlayList_UsesThePlayListPictureProcessKind() - { - var message = new UpdatePlayListPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); - var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListCreation, ActivityNames.PublishPlayListPictureProcessingEvent); + } - AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, ActivityNames.PublishPlayListPictureProcessingEvent); - } + [Fact] + public void PictureSource_UpdatePlayList_UsesThePlayListPictureProcessKind() + { + var message = new UpdatePlayListPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); - [Fact] - public void AudioWorkflow_ChainsDownloadTranscodeTransferAndUpdate() - { - var configuration = new WorkerConfiguration(); - var message = new UpdateTrackAudioEvent(SubjectId, "source-bucket", "source-key", "destination-bucket", "destination-folder"); + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); - var slip = new AudioWorkflowRoutingSlipBuilder(configuration).Build(message, CorrelationId).Build(); + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, ActivityNames.PublishPlayListPictureProcessingEvent); + } - Assert.Equal( - [ - ActivityNames.GenerateAudioWorkflowPaths, - ActivityNames.DownloadFile, - ActivityNames.TranscodeAudio, - ActivityNames.TransferFiles, - ActivityNames.UpdateTrackAudio, - ], - slip.Itinerary.Select(activity => activity.Name)); - Assert.Equal(RoutingSlipVariableNames.ProcessKinds.TrackAudio, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); - Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); - Assert.All(slip.Itinerary, activity => Assert.EndsWith("_execute", activity.Address.ToString())); - } + [Fact] + public void AudioWorkflow_ChainsDownloadTranscodeTransferAndUpdate() + { + var configuration = new WorkerConfiguration(); + var message = new UpdateTrackAudioEvent(SubjectId, "source-bucket", "source-key", "destination-bucket", "destination-folder"); - private static void AssertPictureSourceSlip(RoutingSlip slip, string processKind, string publishStep) - { - Assert.Equal( - [ActivityNames.CopyPictureToFinal, ActivityNames.ConsumeUploadIntents, publishStep], - slip.Itinerary.Select(activity => activity.Name)); + var slip = new AudioWorkflowRoutingSlipBuilder(configuration).Build(message, CorrelationId).Build(); - Assert.Equal(processKind, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); - Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); - Assert.Equal("bucket", slip.Variables[RoutingSlipVariableNames.Workflow.Bucket]); - Assert.Equal("final-key", slip.Variables[RoutingSlipVariableNames.Workflow.PictureKey]); - Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + Assert.Equal( + [ + ActivityNames.GenerateAudioWorkflowPaths, + ActivityNames.DownloadFile, + ActivityNames.TranscodeAudio, + ActivityNames.TransferFiles, + ActivityNames.UpdateTrackAudio, + ], + slip.Itinerary.Select(activity => activity.Name)); + Assert.Equal(RoutingSlipVariableNames.ProcessKinds.TrackAudio, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); + Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); + Assert.All(slip.Itinerary, activity => Assert.EndsWith("_execute", activity.Address.ToString())); + } - var copy = new Dictionary(slip.Itinerary[0].Arguments, StringComparer.OrdinalIgnoreCase); - Assert.Equal("source-key", copy["SourceKey"]?.ToString()); - Assert.Equal("final-key", copy["DestinationKey"]?.ToString()); + private static void AssertPictureSourceSlip(RoutingSlip slip, string processKind, string publishStep) + { + Assert.Equal( + [ActivityNames.CopyPictureToFinal, ActivityNames.ConsumeUploadIntents, publishStep], + slip.Itinerary.Select(activity => activity.Name)); + + Assert.Equal(processKind, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); + Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); + Assert.Equal("bucket", slip.Variables[RoutingSlipVariableNames.Workflow.Bucket]); + Assert.Equal("final-key", slip.Variables[RoutingSlipVariableNames.Workflow.PictureKey]); + Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + + var copy = new Dictionary(slip.Itinerary[0].Arguments, StringComparer.OrdinalIgnoreCase); + Assert.Equal("source-key", copy["SourceKey"]?.ToString()); + Assert.Equal("final-key", copy["DestinationKey"]?.ToString()); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs index 3378b39..0f85942 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs @@ -14,247 +14,248 @@ using Musify.Infrastructure.MassTransit.RoutingSlip; using Musify.Infrastructure.Tests.TestSupport; using Xunit; -namespace Musify.Infrastructure.Tests.MassTransit; - -/// Runs the picture-update activities inside real routing slips on the MassTransit test harness. -[Collection(InfrastructureCollection.Name)] -public sealed class UpdatePicturesActivityTests(InfrastructureTestFixture fixture) +namespace Musify.Infrastructure.Tests.MassTransit { - private const string SmallVariable = "Picture.Small"; - private const string MediumVariable = "Picture.Medium"; - private const string LargeVariable = "Picture.Large"; - - private static CancellationToken Ct => TestContext.Current.CancellationToken; - - [Fact] - public async Task Album_Execute_StoresTheNamesAndPublishesTheEvent() + /// Runs the picture-update activities inside real routing slips on the MassTransit test harness. + [Collection(InfrastructureCollection.Name)] + public sealed class UpdatePicturesActivityTests(InfrastructureTestFixture fixture) { - var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp")); - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); + private const string SmallVariable = "Picture.Small"; + private const string MediumVariable = "Picture.Medium"; + private const string LargeVariable = "Picture.Large"; - var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp"); - await harness.Bus.Execute(slip.Build(), Ct); + private static CancellationToken Ct => TestContext.Current.CancellationToken; - Assert.True(await harness.Sent.Any(Ct)); - await using var verifier = fixture.CreateDatabase(); - var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!; - Assert.Equal("new-original.webp", stored.OriginalName); - Assert.Equal("small.webp", stored.SmallName); - Assert.Equal("medium.webp", stored.MediumName); - Assert.Equal("large.webp", stored.LargeName); - Assert.True(await harness.Published.Any(published => published.Context.Message.AlbumId == album.Id, Ct)); - } - - [Fact] - public async Task PlayList_Execute_StoresTheNamesAndPublishesTheEvent() - { - var playList = await SeedPlayListAsync(EntityPictures.Pending("old.webp")); - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - - var slip = SlipFor(UpdatePlayListPictureActivity.ExecuteEndpointName, playList.Id, "uploads/1/playlists/new.webp"); - await harness.Bus.Execute(slip.Build(), Ct); - - Assert.True(await harness.Sent.Any(Ct)); - await using var verifier = fixture.CreateDatabase(); - var stored = (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).Pictures!; - Assert.Equal("new.webp", stored.OriginalName); - Assert.Equal("small.webp", stored.SmallName); - Assert.True(await harness.Published.Any(published => published.Context.Message.PlayListId == playList.Id, Ct)); - } - - [Fact] - public async Task Track_Execute_MarksThePicturesAsCompleted() - { - var track = await SeedTrackAsync(); - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - - var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp"); - await harness.Bus.Execute(slip.Build(), Ct); - - Assert.True(await harness.Sent.Any(Ct)); - await using var verifier = fixture.CreateDatabase(); - var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures; - Assert.Equal(ProcessingStatus.Completed, stored.ProcessingStatus); - Assert.Equal("large.webp", stored.LargeName); - Assert.True(await harness.Published.Any(published => published.Context.Message.TrackId == track.Id, Ct)); - } - - [Fact] - public async Task Execute_MissingEntity_FaultsTheSlip() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - - var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, Guid.NewGuid(), "uploads/1/albums/x.webp"); - await harness.Bus.Execute(slip.Build(), Ct); - - Assert.True(await harness.Sent.Any(Ct)); - } - - [Fact] - public async Task Execute_MissingVariable_FaultsTheSlip() - { - var album = await SeedAlbumAsync(EntityPictures.Pending("old.webp")); - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - - var slip = RoutingSlips.Create(null).AddStep( - "Update", - UpdateAlbumPictureActivity.ExecuteEndpointName, - new UpdatePicturesArguments(album.Id, "key.webp", SmallVariable, MediumVariable, LargeVariable)); - await harness.Bus.Execute(slip.Build(), Ct); - - Assert.True(await harness.Sent.Any(Ct)); - await using var verifier = fixture.CreateDatabase(); - Assert.Equal("old.webp", (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName); - } - - [Fact] - public async Task Album_LaterStepFails_CompensationRestoresThePreviousNames() - { - var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp")); - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - - var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp"); - slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments()); - await harness.Bus.Execute(slip.Build(), Ct); - - Assert.True(await harness.Sent.Any(Ct)); - await WaitUntilAsync(async () => + [Fact] + public async Task Album_Execute_StoresTheNamesAndPublishesTheEvent() { - await using var poll = fixture.CreateDatabase(); - return (await poll.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName == "old-original.webp"; - }); - await using var verifier = fixture.CreateDatabase(); - var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!; - Assert.Equal("old-original.webp", stored.OriginalName); - Assert.Null(stored.SmallName); - Assert.Null(stored.MediumName); - Assert.Null(stored.LargeName); - } + var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); - [Fact] - public async Task Track_LaterStepFails_CompensationMarksThePicturesAsFailed() - { - var track = await SeedTrackAsync(); - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); + var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp"); + await harness.Bus.Execute(slip.Build(), Ct); - var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp"); - slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments()); - await harness.Bus.Execute(slip.Build(), Ct); + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!; + Assert.Equal("new-original.webp", stored.OriginalName); + Assert.Equal("small.webp", stored.SmallName); + Assert.Equal("medium.webp", stored.MediumName); + Assert.Equal("large.webp", stored.LargeName); + Assert.True(await harness.Published.Any(published => published.Context.Message.AlbumId == album.Id, Ct)); + } - Assert.True(await harness.Sent.Any(Ct)); - await WaitUntilAsync(async () => + [Fact] + public async Task PlayList_Execute_StoresTheNamesAndPublishesTheEvent() { - await using var poll = fixture.CreateDatabase(); - return (await poll.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures.ProcessingStatus == ProcessingStatus.Failed; - }); - await using var verifier = fixture.CreateDatabase(); - var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures; - Assert.Equal(ProcessingStatus.Failed, stored.ProcessingStatus); - Assert.Equal("old.webp", stored.OriginalName); - } + var playList = await SeedPlayListAsync(EntityPictures.Pending("old.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); - private static async Task WaitUntilAsync(Func> condition) - { - var deadline = DateTime.UtcNow.AddSeconds(10); - while (!await condition() && DateTime.UtcNow < deadline) - await Task.Delay(100, Ct); - } + var slip = SlipFor(UpdatePlayListPictureActivity.ExecuteEndpointName, playList.Id, "uploads/1/playlists/new.webp"); + await harness.Bus.Execute(slip.Build(), Ct); - private static RoutingSlipBuilder SlipFor(string endpointName, Guid subjectId, string originalKey) - { - var builder = RoutingSlips.Create(null).AddStep( - "Update", endpointName, new UpdatePicturesArguments(subjectId, originalKey, SmallVariable, MediumVariable, LargeVariable)); - builder.AddVariable(SmallVariable, "pictures/small.webp"); - builder.AddVariable(MediumVariable, "pictures/medium.webp"); - builder.AddVariable(LargeVariable, "pictures/large.webp"); - return builder; - } + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).Pictures!; + Assert.Equal("new.webp", stored.OriginalName); + Assert.Equal("small.webp", stored.SmallName); + Assert.True(await harness.Published.Any(published => published.Context.Message.PlayListId == playList.Id, Ct)); + } - private ServiceProvider CreateProvider() => - new ServiceCollection() - .AddScoped(_ => fixture.CreateDatabase()) - .AddMassTransitTestHarness(bus => + [Fact] + public async Task Track_Execute_MarksThePicturesAsCompleted() + { + var track = await SeedTrackAsync(); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp"); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures; + Assert.Equal(ProcessingStatus.Completed, stored.ProcessingStatus); + Assert.Equal("large.webp", stored.LargeName); + Assert.True(await harness.Published.Any(published => published.Context.Message.TrackId == track.Id, Ct)); + } + + [Fact] + public async Task Execute_MissingEntity_FaultsTheSlip() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, Guid.NewGuid(), "uploads/1/albums/x.webp"); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + } + + [Fact] + public async Task Execute_MissingVariable_FaultsTheSlip() + { + var album = await SeedAlbumAsync(EntityPictures.Pending("old.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = RoutingSlips.Create(null).AddStep( + "Update", + UpdateAlbumPictureActivity.ExecuteEndpointName, + new UpdatePicturesArguments(album.Id, "key.webp", SmallVariable, MediumVariable, LargeVariable)); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + Assert.Equal("old.webp", (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName); + } + + [Fact] + public async Task Album_LaterStepFails_CompensationRestoresThePreviousNames() + { + var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp"); + slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments()); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await WaitUntilAsync(async () => { - bus.SetKebabCaseEndpointNameFormatter(); - bus.AddActivity(); - bus.AddActivity(); - bus.AddActivity(); - bus.AddExecuteActivity(); - }) - .BuildServiceProvider(true); + await using var poll = fixture.CreateDatabase(); + return (await poll.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName == "old-original.webp"; + }); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!; + Assert.Equal("old-original.webp", stored.OriginalName); + Assert.Null(stored.SmallName); + Assert.Null(stored.MediumName); + Assert.Null(stored.LargeName); + } - private static async Task StartAsync(ServiceProvider provider) - { - var harness = provider.GetRequiredService(); - await harness.Start(); - return harness; - } - - private async Task SeedUserAsync() - { - await using var database = fixture.CreateDatabase(); - var name = $"pictures-{Guid.NewGuid():N}"[..30]; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; - await database.Users.AddAsync(user, Ct); - await database.SaveChangesAsync(Ct); - return user; - } - - private async Task SeedAlbumAsync(EntityPictures pictures) - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var title = $"album-{Guid.NewGuid():N}"; - var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; - await database.Albums.AddAsync(album, Ct); - await database.SaveChangesAsync(Ct); - return album; - } - - private async Task SeedPlayListAsync(EntityPictures pictures) - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var name = $"pl-{Guid.NewGuid():N}"[..30]; - var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; - await database.PlayLists.AddAsync(playList, Ct); - await database.SaveChangesAsync(Ct); - return playList; - } - - private async Task SeedTrackAsync() - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var title = $"track-{Guid.NewGuid():N}"; - var track = new Track + [Fact] + public async Task Track_LaterStepFails_CompensationMarksThePicturesAsFailed() { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - DurationSeconds = 10, - OwnerUserId = user.Id, - Pictures = new TrackPictures { OriginalName = "old.webp" }, - Audio = new TrackAudio() - }; - await database.Tracks.AddAsync(track, Ct); - await database.SaveChangesAsync(Ct); - return track; - } + var track = await SeedTrackAsync(); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); - public sealed record AlwaysFailsArguments; + var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp"); + slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments()); + await harness.Bus.Execute(slip.Build(), Ct); - public sealed class AlwaysFailsActivity : IExecuteActivity - { - public const string ExecuteEndpointName = "always-fails"; + Assert.True(await harness.Sent.Any(Ct)); + await WaitUntilAsync(async () => + { + await using var poll = fixture.CreateDatabase(); + return (await poll.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures.ProcessingStatus == ProcessingStatus.Failed; + }); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures; + Assert.Equal(ProcessingStatus.Failed, stored.ProcessingStatus); + Assert.Equal("old.webp", stored.OriginalName); + } - public Task Execute(ExecuteContext context) => - throw new InvalidOperationException("Forced failure"); + private static async Task WaitUntilAsync(Func> condition) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (!await condition() && DateTime.UtcNow < deadline) + await Task.Delay(100, Ct); + } + + private static RoutingSlipBuilder SlipFor(string endpointName, Guid subjectId, string originalKey) + { + var builder = RoutingSlips.Create(null).AddStep( + "Update", endpointName, new UpdatePicturesArguments(subjectId, originalKey, SmallVariable, MediumVariable, LargeVariable)); + builder.AddVariable(SmallVariable, "pictures/small.webp"); + builder.AddVariable(MediumVariable, "pictures/medium.webp"); + builder.AddVariable(LargeVariable, "pictures/large.webp"); + return builder; + } + + private ServiceProvider CreateProvider() => + new ServiceCollection() + .AddScoped(_ => fixture.CreateDatabase()) + .AddMassTransitTestHarness(bus => + { + bus.SetKebabCaseEndpointNameFormatter(); + bus.AddActivity(); + bus.AddActivity(); + bus.AddActivity(); + bus.AddExecuteActivity(); + }) + .BuildServiceProvider(true); + + private static async Task StartAsync(ServiceProvider provider) + { + var harness = provider.GetRequiredService(); + await harness.Start(); + return harness; + } + + private async Task SeedUserAsync() + { + await using var database = fixture.CreateDatabase(); + var name = $"pictures-{Guid.NewGuid():N}"[..30]; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, Ct); + await database.SaveChangesAsync(Ct); + return user; + } + + private async Task SeedAlbumAsync(EntityPictures pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"album-{Guid.NewGuid():N}"; + var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.Albums.AddAsync(album, Ct); + await database.SaveChangesAsync(Ct); + return album; + } + + private async Task SeedPlayListAsync(EntityPictures pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var name = $"pl-{Guid.NewGuid():N}"[..30]; + var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.PlayLists.AddAsync(playList, Ct); + await database.SaveChangesAsync(Ct); + return playList; + } + + private async Task SeedTrackAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"track-{Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 10, + OwnerUserId = user.Id, + Pictures = new TrackPictures { OriginalName = "old.webp" }, + Audio = new TrackAudio() + }; + await database.Tracks.AddAsync(track, Ct); + await database.SaveChangesAsync(Ct); + return track; + } + + public sealed record AlwaysFailsArguments; + + public sealed class AlwaysFailsActivity : IExecuteActivity + { + public const string ExecuteEndpointName = "always-fails"; + + public Task Execute(ExecuteContext context) => + throw new InvalidOperationException("Forced failure"); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs index a846be2..db7ce42 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs @@ -3,51 +3,52 @@ using Musify.Domain.Entities; using Musify.Infrastructure.Tests.TestSupport; using Xunit; -namespace Musify.Infrastructure.Tests.Persistence; - -[Collection(InfrastructureCollection.Name)] -public sealed class AuditableEntityInterceptorTests(InfrastructureTestFixture fixture) +namespace Musify.Infrastructure.Tests.Persistence { - [Fact] - public async Task SavingChanges_NewEntity_SetsCreatedAtAndUpdatedAtToNow() + [Collection(InfrastructureCollection.Name)] + public sealed class AuditableEntityInterceptorTests(InfrastructureTestFixture fixture) { - await using var database = fixture.CreateDatabase(); - var before = DateTime.UtcNow; + [Fact] + public async Task SavingChanges_NewEntity_SetsCreatedAtAndUpdatedAtToNow() + { + await using var database = fixture.CreateDatabase(); + var before = DateTime.UtcNow; - var name = "audit-user"; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; - await database.Users.AddAsync(user, TestContext.Current.CancellationToken); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); + var name = "audit-user"; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, TestContext.Current.CancellationToken); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); - var after = DateTime.UtcNow; + var after = DateTime.UtcNow; - Assert.InRange(user.CreatedAt, before, after); - Assert.InRange(user.UpdatedAt, before, after); - } + Assert.InRange(user.CreatedAt, before, after); + Assert.InRange(user.UpdatedAt, before, after); + } - [Fact] - public async Task SavingChanges_ModifiedEntity_BumpsUpdatedAtButKeepsCreatedAt() - { - await using var database = fixture.CreateDatabase(); + [Fact] + public async Task SavingChanges_ModifiedEntity_BumpsUpdatedAtButKeepsCreatedAt() + { + await using var database = fixture.CreateDatabase(); - var name = "audit-user-2"; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; - await database.Users.AddAsync(user, TestContext.Current.CancellationToken); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); - var originalCreatedAt = user.CreatedAt; + var name = "audit-user-2"; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, TestContext.Current.CancellationToken); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + var originalCreatedAt = user.CreatedAt; - await Task.Delay(TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken); + await Task.Delay(TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken); - user.Name = "audit-user-2-renamed"; - await database.SaveChangesAsync(TestContext.Current.CancellationToken); + user.Name = "audit-user-2-renamed"; + await database.SaveChangesAsync(TestContext.Current.CancellationToken); - Assert.Equal(originalCreatedAt, user.CreatedAt); - Assert.True(user.UpdatedAt > originalCreatedAt, - $"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}"); + Assert.Equal(originalCreatedAt, user.CreatedAt); + Assert.True(user.UpdatedAt > originalCreatedAt, + $"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}"); - await using var reader = fixture.CreateDatabase(); - var stored = await reader.Users.SingleAsync(u => u.Id == user.Id, TestContext.Current.CancellationToken); - Assert.True(stored.UpdatedAt > stored.CreatedAt, - $"stored-created-at={stored.CreatedAt:O}, stored-updated-at={stored.UpdatedAt:O}"); + await using var reader = fixture.CreateDatabase(); + var stored = await reader.Users.SingleAsync(u => u.Id == user.Id, TestContext.Current.CancellationToken); + Assert.True(stored.UpdatedAt > stored.CreatedAt, + $"stored-created-at={stored.CreatedAt:O}, stored-updated-at={stored.UpdatedAt:O}"); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs index 57c7c26..3265b9e 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs @@ -4,104 +4,105 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.Tests.TestSupport; using Xunit; -namespace Musify.Infrastructure.Tests.Persistence; - -[Collection(InfrastructureCollection.Name)] -public sealed class DatabaseTests(InfrastructureTestFixture fixture) +namespace Musify.Infrastructure.Tests.Persistence { - [Fact] - public async Task SaveChangesAsync_TrackWithOwnedTypes_RoundTripsThroughPostgres() + [Collection(InfrastructureCollection.Name)] + public sealed class DatabaseTests(InfrastructureTestFixture fixture) { - await using var database = fixture.CreateDatabase(); - - var userName = "roundtrip-user"; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; - await database.Users.AddAsync(user, TestContext.Current.CancellationToken); - - var trackTitle = "Round Trip Track"; - var track = new Track + [Fact] + public async Task SaveChangesAsync_TrackWithOwnedTypes_RoundTripsThroughPostgres() { - Title = trackTitle, - NormalizedTitle = trackTitle.ToUpperInvariant(), - DurationSeconds = 123.4, - OwnerUserId = user.Id, - Owner = user, - Pictures = new TrackPictures { OriginalName = "cover.webp", ProcessingStatus = ProcessingStatus.Pending }, - Audio = new TrackAudio { OriginalName = "song.mp3", TranscodeStatus = ProcessingStatus.Pending } - }; - await database.Tracks.AddAsync(track, TestContext.Current.CancellationToken); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); + await using var database = fixture.CreateDatabase(); - await using var reloaded = fixture.CreateDatabase(); - var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken); + var userName = "roundtrip-user"; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; + await database.Users.AddAsync(user, TestContext.Current.CancellationToken); - Assert.Equal(trackTitle, stored.Title); - Assert.Equal("cover.webp", stored.Pictures.OriginalName); - Assert.Equal("song.mp3", stored.Audio.OriginalName); - Assert.Equal(user.Id, stored.OwnerUserId); - } + var trackTitle = "Round Trip Track"; + var track = new Track + { + Title = trackTitle, + NormalizedTitle = trackTitle.ToUpperInvariant(), + DurationSeconds = 123.4, + OwnerUserId = user.Id, + Owner = user, + Pictures = new TrackPictures { OriginalName = "cover.webp", ProcessingStatus = ProcessingStatus.Pending }, + Audio = new TrackAudio { OriginalName = "song.mp3", TranscodeStatus = ProcessingStatus.Pending } + }; + await database.Tracks.AddAsync(track, TestContext.Current.CancellationToken); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); - [Fact] - public async Task SaveChangesAsync_TrackWithTags_RoundTripsThroughNativePostgresEnum() - { - await using var database = fixture.CreateDatabase(); + await using var reloaded = fixture.CreateDatabase(); + var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken); - var userName = "tags-roundtrip-user"; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; - var track = BuildTrack(user, $"tags-{Guid.NewGuid():N}"); - track.Tags = - [ - new TrackTag { TrackId = track.Id, Tag = Genre.Pop }, - new TrackTag { TrackId = track.Id, Tag = Genre.Rock } - ]; + Assert.Equal(trackTitle, stored.Title); + Assert.Equal("cover.webp", stored.Pictures.OriginalName); + Assert.Equal("song.mp3", stored.Audio.OriginalName); + Assert.Equal(user.Id, stored.OwnerUserId); + } - await database.AddRangeAsync(user, track); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); - - await using var reloaded = fixture.CreateDatabase(); - var stored = await reloaded.Tracks.AsNoTracking() - .Include(t => t.Tags) - .SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken); - - Assert.Equal([Genre.Pop, Genre.Rock], stored.Tags.Select(t => t.Tag).OrderBy(t => t)); - } - - [Fact] - public async Task AlbumHasTracks_DeletingAlbum_CascadesToTheLinkRows() - { - await using var database = fixture.CreateDatabase(); - - var userName = "cascade-user"; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; - var albumTitle = "Cascade Album"; - var album = new Album { Title = albumTitle, NormalizedTitle = albumTitle.ToUpperInvariant(), OwnerUserId = user.Id }; - var track = BuildTrack(user, $"cascade-{Guid.NewGuid():N}"); - var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }; - - await database.AddRangeAsync(user, album, track, link); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); - - await using var deleter = fixture.CreateDatabase(); - var albumToDelete = await deleter.Albums.SingleAsync(a => a.Id == album.Id, TestContext.Current.CancellationToken); - deleter.Albums.Remove(albumToDelete); - await deleter.SaveChangesAsync(TestContext.Current.CancellationToken); - - await using var verifier = fixture.CreateDatabase(); - Assert.False(await verifier.AlbumHasTracks.AnyAsync(l => l.AlbumId == album.Id, TestContext.Current.CancellationToken)); - Assert.True(await verifier.Tracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken)); - } - - private static Track BuildTrack(User owner, string title) - { - return new Track + [Fact] + public async Task SaveChangesAsync_TrackWithTags_RoundTripsThroughNativePostgresEnum() { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - DurationSeconds = 100, - OwnerUserId = owner.Id, - Owner = owner, - Pictures = new TrackPictures(), - Audio = new TrackAudio() - }; + await using var database = fixture.CreateDatabase(); + + var userName = "tags-roundtrip-user"; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; + var track = BuildTrack(user, $"tags-{Guid.NewGuid():N}"); + track.Tags = + [ + new TrackTag { TrackId = track.Id, Tag = Genre.Pop }, + new TrackTag { TrackId = track.Id, Tag = Genre.Rock } + ]; + + await database.AddRangeAsync(user, track); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + + await using var reloaded = fixture.CreateDatabase(); + var stored = await reloaded.Tracks.AsNoTracking() + .Include(t => t.Tags) + .SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken); + + Assert.Equal([Genre.Pop, Genre.Rock], stored.Tags.Select(t => t.Tag).OrderBy(t => t)); + } + + [Fact] + public async Task AlbumHasTracks_DeletingAlbum_CascadesToTheLinkRows() + { + await using var database = fixture.CreateDatabase(); + + var userName = "cascade-user"; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; + var albumTitle = "Cascade Album"; + var album = new Album { Title = albumTitle, NormalizedTitle = albumTitle.ToUpperInvariant(), OwnerUserId = user.Id }; + var track = BuildTrack(user, $"cascade-{Guid.NewGuid():N}"); + var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }; + + await database.AddRangeAsync(user, album, track, link); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + + await using var deleter = fixture.CreateDatabase(); + var albumToDelete = await deleter.Albums.SingleAsync(a => a.Id == album.Id, TestContext.Current.CancellationToken); + deleter.Albums.Remove(albumToDelete); + await deleter.SaveChangesAsync(TestContext.Current.CancellationToken); + + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.AlbumHasTracks.AnyAsync(l => l.AlbumId == album.Id, TestContext.Current.CancellationToken)); + Assert.True(await verifier.Tracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken)); + } + + private static Track BuildTrack(User owner, string title) + { + return new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 100, + OwnerUserId = owner.Id, + Owner = owner, + Pictures = new TrackPictures(), + Audio = new TrackAudio() + }; + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/PictureServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/PictureServiceTests.cs index bf893c8..7b0f086 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/PictureServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/PictureServiceTests.cs @@ -4,54 +4,55 @@ using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using Xunit; -namespace Musify.Infrastructure.Tests.Services; - -public sealed class PictureServiceTests +namespace Musify.Infrastructure.Tests.Services { - private static PictureService CreateService() => new(NullLogger.Instance); - - private static MemoryStream CreateSourceImage(int width, int height) + public sealed class PictureServiceTests { - using var image = new Image(width, height, new Rgba32(255, 0, 0)); - var stream = new MemoryStream(); - image.SaveAsPng(stream); - stream.Position = 0; - return stream; - } + private static PictureService CreateService() => new(NullLogger.Instance); - [Fact] - public async Task ResizePictureAsWebpAsync_LargerThanTarget_ShrinksToFitWithinTheRequestedBox() - { - using var source = CreateSourceImage(800, 600); + private static MemoryStream CreateSourceImage(int width, int height) + { + using var image = new Image(width, height, new Rgba32(255, 0, 0)); + var stream = new MemoryStream(); + image.SaveAsPng(stream); + stream.Position = 0; + return stream; + } - await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 200, 200, TestContext.Current.CancellationToken); + [Fact] + public async Task ResizePictureAsWebpAsync_LargerThanTarget_ShrinksToFitWithinTheRequestedBox() + { + using var source = CreateSourceImage(800, 600); - using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken); - Assert.InRange(result.Width, 1, 200); - Assert.InRange(result.Height, 1, 200); - Assert.True(result.Width == 200 || result.Height == 200, "expected the longer side to land exactly on the target box"); - } + await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 200, 200, TestContext.Current.CancellationToken); - [Fact] - public async Task ResizePictureAsWebpAsync_ReturnsAValidWebpStream() - { - using var source = CreateSourceImage(100, 100); + using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken); + Assert.InRange(result.Width, 1, 200); + Assert.InRange(result.Height, 1, 200); + Assert.True(result.Width == 200 || result.Height == 200, "expected the longer side to land exactly on the target box"); + } - await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 50, 50, TestContext.Current.CancellationToken); + [Fact] + public async Task ResizePictureAsWebpAsync_ReturnsAValidWebpStream() + { + using var source = CreateSourceImage(100, 100); - var format = await Image.DetectFormatAsync(resized, TestContext.Current.CancellationToken); - Assert.Equal("WEBP", format?.Name, ignoreCase: true); - } + await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 50, 50, TestContext.Current.CancellationToken); - [Fact] - public async Task ResizePictureAsWebpAsync_PreservesAspectRatio() - { - using var source = CreateSourceImage(400, 200); + var format = await Image.DetectFormatAsync(resized, TestContext.Current.CancellationToken); + Assert.Equal("WEBP", format?.Name, ignoreCase: true); + } - await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 100, 100, TestContext.Current.CancellationToken); + [Fact] + public async Task ResizePictureAsWebpAsync_PreservesAspectRatio() + { + using var source = CreateSourceImage(400, 200); - using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken); - Assert.Equal(100, result.Width); - Assert.Equal(50, result.Height); + await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 100, 100, TestContext.Current.CancellationToken); + + using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken); + Assert.Equal(100, result.Width); + Assert.Equal(50, result.Height); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs index f96cbba..f58fd78 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs @@ -7,130 +7,131 @@ using Musify.Infrastructure.Services; using Musify.Infrastructure.Tests.TestSupport; using Xunit; -namespace Musify.Infrastructure.Tests.Services; - -[Collection(InfrastructureCollection.Name)] -public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime +namespace Musify.Infrastructure.Tests.Services { - private StorageService service = null!; - - public ValueTask InitializeAsync() + [Collection(InfrastructureCollection.Name)] + public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime { - var storageConfiguration = new InfrastructureStorageConfiguration + private StorageService service = null!; + + public ValueTask InitializeAsync() { - Address = fixture.S3ServiceUrl, - AccessKey = InfrastructureTestFixture.S3AccessKey, - SecretAccessKey = InfrastructureTestFixture.S3SecretKey, - ForcePathStyle = true, - UseHttp = true, - }; - service = new StorageService(fixture.CreateS3Client(), NullLogger.Instance, storageConfiguration); - return ValueTask.CompletedTask; - } + var storageConfiguration = new InfrastructureStorageConfiguration + { + Address = fixture.S3ServiceUrl, + AccessKey = InfrastructureTestFixture.S3AccessKey, + SecretAccessKey = InfrastructureTestFixture.S3SecretKey, + ForcePathStyle = true, + UseHttp = true, + }; + service = new StorageService(fixture.CreateS3Client(), NullLogger.Instance, storageConfiguration); + return ValueTask.CompletedTask; + } - public ValueTask DisposeAsync() => ValueTask.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; - [Fact] - public async Task UploadThenGetFile_RoundTripsTheSameBytes() - { - var key = $"tests/{Guid.NewGuid():N}/round-trip.txt"; - var content = "hello seaweedfs"u8.ToArray(); - - await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); - - await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); - Assert.NotNull(stream); - using var reader = new StreamReader(stream, Encoding.UTF8); - var readBack = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); - - Assert.Equal("hello seaweedfs", readBack); - } - - [Fact] - public async Task GetFileAsync_MissingObject_ReturnsNull() - { - var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken); - - Assert.Null(stream); - } - - [Fact] - public async Task HeadObjectAsync_ExistingObject_ReturnsMetadata() - { - var key = $"tests/{Guid.NewGuid():N}/head.txt"; - await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); - - var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); - - Assert.NotNull(metadata); - Assert.Equal(1, metadata.ContentLength); - } - - [Fact] - public async Task HeadObjectAsync_MissingObject_ReturnsNull() - { - var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken); - - Assert.Null(metadata); - } - - [Fact] - public async Task RemoveFileAsync_ExistingObject_DeletesIt() - { - var key = $"tests/{Guid.NewGuid():N}/to-remove.txt"; - await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); - - await service.RemoveFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); - - Assert.Null(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task CopyFileAsync_CopiesToTheDestinationKey() - { - var sourceKey = $"tests/{Guid.NewGuid():N}/source.txt"; - var destinationKey = $"tests/{Guid.NewGuid():N}/destination.txt"; - await service.UploadFileAsync(new MemoryStream("copy-me"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, sourceKey, TestContext.Current.CancellationToken); - - await service.CopyFileAsync(InfrastructureTestFixture.S3Bucket, sourceKey, InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken); - - Assert.NotNull(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task ListObjectsAsync_ReturnsEveryObjectUnderThePrefix() - { - var prefix = $"tests/{Guid.NewGuid():N}"; - await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken); - await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/b.txt", TestContext.Current.CancellationToken); - - var keys = new List(); - await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken)) - keys.Add(item.Key); - - Assert.Equal([$"{prefix}/a.txt", $"{prefix}/b.txt"], keys.Order()); - } - - [Fact] - public async Task GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject() - { - var key = $"tests/{Guid.NewGuid():N}/presigned.txt"; - - var uploadUrl = await service.GetUploadUrlAsync( - InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), cancellationToken: TestContext.Current.CancellationToken); - - using var httpClient = new HttpClient(); - using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl) + [Fact] + public async Task UploadThenGetFile_RoundTripsTheSameBytes() { - Content = new ByteArrayContent(Encoding.UTF8.GetBytes("presigned content")) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); - request.Headers.TryAddWithoutValidation("If-None-Match", "*"); - var response = await httpClient.SendAsync(request, TestContext.Current.CancellationToken); + var key = $"tests/{Guid.NewGuid():N}/round-trip.txt"; + var content = "hello seaweedfs"u8.ToArray(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); - Assert.NotNull(metadata); - Assert.Equal("presigned content".Length, metadata.ContentLength); + await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + + await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + Assert.NotNull(stream); + using var reader = new StreamReader(stream, Encoding.UTF8); + var readBack = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); + + Assert.Equal("hello seaweedfs", readBack); + } + + [Fact] + public async Task GetFileAsync_MissingObject_ReturnsNull() + { + var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken); + + Assert.Null(stream); + } + + [Fact] + public async Task HeadObjectAsync_ExistingObject_ReturnsMetadata() + { + var key = $"tests/{Guid.NewGuid():N}/head.txt"; + await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + + var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + + Assert.NotNull(metadata); + Assert.Equal(1, metadata.ContentLength); + } + + [Fact] + public async Task HeadObjectAsync_MissingObject_ReturnsNull() + { + var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken); + + Assert.Null(metadata); + } + + [Fact] + public async Task RemoveFileAsync_ExistingObject_DeletesIt() + { + var key = $"tests/{Guid.NewGuid():N}/to-remove.txt"; + await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + + await service.RemoveFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + + Assert.Null(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CopyFileAsync_CopiesToTheDestinationKey() + { + var sourceKey = $"tests/{Guid.NewGuid():N}/source.txt"; + var destinationKey = $"tests/{Guid.NewGuid():N}/destination.txt"; + await service.UploadFileAsync(new MemoryStream("copy-me"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, sourceKey, TestContext.Current.CancellationToken); + + await service.CopyFileAsync(InfrastructureTestFixture.S3Bucket, sourceKey, InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken); + + Assert.NotNull(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ListObjectsAsync_ReturnsEveryObjectUnderThePrefix() + { + var prefix = $"tests/{Guid.NewGuid():N}"; + await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken); + await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/b.txt", TestContext.Current.CancellationToken); + + var keys = new List(); + await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken)) + keys.Add(item.Key); + + Assert.Equal([$"{prefix}/a.txt", $"{prefix}/b.txt"], keys.Order()); + } + + [Fact] + public async Task GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject() + { + var key = $"tests/{Guid.NewGuid():N}/presigned.txt"; + + var uploadUrl = await service.GetUploadUrlAsync( + InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), cancellationToken: TestContext.Current.CancellationToken); + + using var httpClient = new HttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl) + { + Content = new ByteArrayContent(Encoding.UTF8.GetBytes("presigned content")) + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); + request.Headers.TryAddWithoutValidation("If-None-Match", "*"); + var response = await httpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + Assert.NotNull(metadata); + Assert.Equal("presigned content".Length, metadata.ContentLength); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs index 155d645..d16e298 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs @@ -5,104 +5,105 @@ using Musify.Infrastructure.Configuration; using Musify.Infrastructure.Services; using Xunit; -namespace Musify.Infrastructure.Tests.Services; - -public sealed class StreamTicketServiceTests : IDisposable +namespace Musify.Infrastructure.Tests.Services { - private readonly string privateKeyPath = Path.GetTempFileName(); - private readonly RSA rsa = RSA.Create(2048); - - public StreamTicketServiceTests() => File.WriteAllText(privateKeyPath, rsa.ExportPkcs8PrivateKeyPem()); - - public void Dispose() + public sealed class StreamTicketServiceTests : IDisposable { - rsa.Dispose(); - File.Delete(privateKeyPath); - } + private readonly string privateKeyPath = Path.GetTempFileName(); + private readonly RSA rsa = RSA.Create(2048); - private StreamTicketService CreateService(int ticketTtlSeconds = 3600) => new(new StreamTicketConfiguration - { - PrivateKeyPath = privateKeyPath, - Audience = "media-gateway", - Issuer = "musify-webapi", - TicketTtlSeconds = ticketTtlSeconds - }); + public StreamTicketServiceTests() => File.WriteAllText(privateKeyPath, rsa.ExportPkcs8PrivateKeyPem()); - [Fact] - public void IssueTicket_ReturnsATokenSignedWithTheConfiguredKeyAndClaims() - { - using var service = CreateService(ticketTtlSeconds: 120); - - var ticket = service.IssueTicket(userId: 42, keyPrefix: "Tracks/ProcessedAudios/abc/"); - - Assert.Equal(120, ticket.ExpiresInSeconds); - - var handler = new JsonWebTokenHandler(); - var jwt = handler.ReadJsonWebToken(ticket.Token); - Assert.Equal("42", jwt.GetClaim("sub").Value); - Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value); - Assert.Equal("musify-webapi", jwt.Issuer); - Assert.Contains("media-gateway", jwt.Audiences); - } - - [Fact] - public void IssueTicket_AnonymousUser_OmitsTheSubClaim() - { - using var service = CreateService(); - - var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/"); - - var handler = new JsonWebTokenHandler(); - var jwt = handler.ReadJsonWebToken(ticket.Token); - Assert.DoesNotContain(jwt.Claims, c => c.Type == "sub"); - Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value); - } - - [Fact] - public void IssueTicket_WithMaxBytes_IncludesTheMaxBytesClaim() - { - using var service = CreateService(); - - var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/", maxBytes: 480_000); - - var handler = new JsonWebTokenHandler(); - var jwt = handler.ReadJsonWebToken(ticket.Token); - Assert.Equal("480000", jwt.GetClaim("maxBytes").Value); - } - - [Fact] - public void IssueTicket_WithoutMaxBytes_OmitsTheMaxBytesClaim() - { - using var service = CreateService(); - - var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/abc/"); - - var handler = new JsonWebTokenHandler(); - var jwt = handler.ReadJsonWebToken(ticket.Token); - Assert.DoesNotContain(jwt.Claims, c => c.Type == "maxBytes"); - } - - [Fact] - public async Task IssueTicket_TokenValidatesAgainstTheMatchingPublicKey() - { - using var service = CreateService(); - var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/xyz/"); - - var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem(); - using var verifyingRsa = RSA.Create(); - verifyingRsa.ImportFromPem(publicKeyPem); - - var handler = new JsonWebTokenHandler(); - var result = await handler.ValidateTokenAsync(ticket.Token, new TokenValidationParameters + public void Dispose() { - ValidIssuer = "musify-webapi", - ValidAudience = "media-gateway", - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - IssuerSigningKey = new RsaSecurityKey(verifyingRsa), + rsa.Dispose(); + File.Delete(privateKeyPath); + } + + private StreamTicketService CreateService(int ticketTtlSeconds = 3600) => new(new StreamTicketConfiguration + { + PrivateKeyPath = privateKeyPath, + Audience = "media-gateway", + Issuer = "musify-webapi", + TicketTtlSeconds = ticketTtlSeconds }); - Assert.True(result.IsValid); + [Fact] + public void IssueTicket_ReturnsATokenSignedWithTheConfiguredKeyAndClaims() + { + using var service = CreateService(ticketTtlSeconds: 120); + + var ticket = service.IssueTicket(userId: 42, keyPrefix: "Tracks/ProcessedAudios/abc/"); + + Assert.Equal(120, ticket.ExpiresInSeconds); + + var handler = new JsonWebTokenHandler(); + var jwt = handler.ReadJsonWebToken(ticket.Token); + Assert.Equal("42", jwt.GetClaim("sub").Value); + Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value); + Assert.Equal("musify-webapi", jwt.Issuer); + Assert.Contains("media-gateway", jwt.Audiences); + } + + [Fact] + public void IssueTicket_AnonymousUser_OmitsTheSubClaim() + { + using var service = CreateService(); + + var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/"); + + var handler = new JsonWebTokenHandler(); + var jwt = handler.ReadJsonWebToken(ticket.Token); + Assert.DoesNotContain(jwt.Claims, c => c.Type == "sub"); + Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value); + } + + [Fact] + public void IssueTicket_WithMaxBytes_IncludesTheMaxBytesClaim() + { + using var service = CreateService(); + + var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/", maxBytes: 480_000); + + var handler = new JsonWebTokenHandler(); + var jwt = handler.ReadJsonWebToken(ticket.Token); + Assert.Equal("480000", jwt.GetClaim("maxBytes").Value); + } + + [Fact] + public void IssueTicket_WithoutMaxBytes_OmitsTheMaxBytesClaim() + { + using var service = CreateService(); + + var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/abc/"); + + var handler = new JsonWebTokenHandler(); + var jwt = handler.ReadJsonWebToken(ticket.Token); + Assert.DoesNotContain(jwt.Claims, c => c.Type == "maxBytes"); + } + + [Fact] + public async Task IssueTicket_TokenValidatesAgainstTheMatchingPublicKey() + { + using var service = CreateService(); + var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/xyz/"); + + var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem(); + using var verifyingRsa = RSA.Create(); + verifyingRsa.ImportFromPem(publicKeyPem); + + var handler = new JsonWebTokenHandler(); + var result = await handler.ValidateTokenAsync(ticket.Token, new TokenValidationParameters + { + ValidIssuer = "musify-webapi", + ValidAudience = "media-gateway", + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + IssuerSigningKey = new RsaSecurityKey(verifyingRsa), + }); + + Assert.True(result.IsValid); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs b/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs index 7ccc578..622507f 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs @@ -10,97 +10,98 @@ using Musify.Infrastructure.Persistence; using Testcontainers.PostgreSql; using Xunit; -namespace Musify.Infrastructure.Tests.TestSupport; - -public sealed class InfrastructureTestFixture : IAsyncLifetime +namespace Musify.Infrastructure.Tests.TestSupport { - public const string S3AccessKey = "musify-test"; - public const string S3SecretKey = "musify-test-secret"; - public const string S3Bucket = "musify-test-bucket"; - - private static readonly string[] S3Actions = ["Admin", "Read", "Write"]; - - private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") - .WithDatabase("musify_test") - .WithUsername("postgres") - .WithPassword("postgres") - .Build(); - - private readonly IContainer seaweedFs = new ContainerBuilder("chrislusf/seaweedfs:latest") - .WithEntrypoint("/bin/sh", "-c") - .WithCommand(BuildSeaweedFsStartupScript()) - .WithPortBinding(9333, assignRandomHostPort: true) - .WithPortBinding(8333, assignRandomHostPort: true) - .WithWaitStrategy(Wait.ForUnixContainer() - .UntilHttpRequestIsSucceeded(request => request.ForPort(9333).ForPath("/cluster/healthz"))) - .Build(); - - public string ConnectionString => postgres.GetConnectionString(); - - public string S3ServiceUrl => $"http://{seaweedFs.Hostname}:{seaweedFs.GetMappedPublicPort(8333)}"; - - public async ValueTask InitializeAsync() + public sealed class InfrastructureTestFixture : IAsyncLifetime { - await Task.WhenAll(postgres.StartAsync(), seaweedFs.StartAsync()); + public const string S3AccessKey = "musify-test"; + public const string S3SecretKey = "musify-test-secret"; + public const string S3Bucket = "musify-test-bucket"; - await using var database = new Database(new DatabaseConfiguration { ConnectionString = ConnectionString }); - await database.Database.MigrateAsync(); + private static readonly string[] S3Actions = ["Admin", "Read", "Write"]; - using var s3 = CreateS3Client(); - await s3.PutBucketAsync(S3Bucket); - } - - public async ValueTask DisposeAsync() - { - await postgres.DisposeAsync(); - await seaweedFs.DisposeAsync(); - } - - public Database CreateDatabase() - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection([new("Database:ConnectionString", ConnectionString)]) + private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase("musify_test") + .WithUsername("postgres") + .WithPassword("postgres") .Build(); - var services = new ServiceCollection(); - services.AddDatabase(configuration); - return services.BuildServiceProvider().GetRequiredService(); - } + private readonly IContainer seaweedFs = new ContainerBuilder("chrislusf/seaweedfs:latest") + .WithEntrypoint("/bin/sh", "-c") + .WithCommand(BuildSeaweedFsStartupScript()) + .WithPortBinding(9333, assignRandomHostPort: true) + .WithPortBinding(8333, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => request.ForPort(9333).ForPath("/cluster/healthz"))) + .Build(); - public IAmazonS3 CreateS3Client() => - new AmazonS3Client(S3AccessKey, S3SecretKey, new AmazonS3Config - { - ServiceURL = S3ServiceUrl, - ForcePathStyle = true, - UseHttp = true, - }); + public string ConnectionString => postgres.GetConnectionString(); - private static string BuildSeaweedFsStartupScript() - { - var identity = JsonSerializer.Serialize(new + public string S3ServiceUrl => $"http://{seaweedFs.Hostname}:{seaweedFs.GetMappedPublicPort(8333)}"; + + public async ValueTask InitializeAsync() { - identities = new[] + await Task.WhenAll(postgres.StartAsync(), seaweedFs.StartAsync()); + + await using var database = new Database(new DatabaseConfiguration { ConnectionString = ConnectionString }); + await database.Database.MigrateAsync(); + + using var s3 = CreateS3Client(); + await s3.PutBucketAsync(S3Bucket); + } + + public async ValueTask DisposeAsync() + { + await postgres.DisposeAsync(); + await seaweedFs.DisposeAsync(); + } + + public Database CreateDatabase() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([new("Database:ConnectionString", ConnectionString)]) + .Build(); + + var services = new ServiceCollection(); + services.AddDatabase(configuration); + return services.BuildServiceProvider().GetRequiredService(); + } + + public IAmazonS3 CreateS3Client() => + new AmazonS3Client(S3AccessKey, S3SecretKey, new AmazonS3Config { - new - { - name = "musify-test", - credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } }, - actions = S3Actions - } - } - }); + ServiceURL = S3ServiceUrl, + ForcePathStyle = true, + UseHttp = true, + }); - return $""" + private static string BuildSeaweedFsStartupScript() + { + var identity = JsonSerializer.Serialize(new + { + identities = new[] + { + new + { + name = "musify-test", + credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } }, + actions = S3Actions + } + } + }); + + return $""" cat > /tmp/s3.conf <<'JSON' {identity} JSON exec weed server -ip.bind=0.0.0.0 -dir=/data -master.volumeSizeLimitMB=1024 -volume.max=0 -filer -s3 -s3.config=/tmp/s3.conf """; + } + } + + [CollectionDefinition(Name)] + public sealed class InfrastructureCollection : ICollectionFixture + { + public const string Name = "Infrastructure"; } } - -[CollectionDefinition(Name)] -public sealed class InfrastructureCollection : ICollectionFixture -{ - public const string Name = "Infrastructure"; -} diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs index d96b479..3bcd9fc 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs @@ -6,159 +6,160 @@ using Musify.StreamingGateway.Authentication; using Musify.StreamingGateway.Configuration; using Xunit; -namespace Musify.StreamingGateway.Tests.Authentication; - -public sealed class TicketValidatorTests : IDisposable +namespace Musify.StreamingGateway.Tests.Authentication { - private const string Issuer = "musify-webapi"; - private const string Audience = "media-gateway"; - - private readonly string publicKeyPath = Path.GetTempFileName(); - private readonly RSA rsa = RSA.Create(2048); - private readonly JsonWebTokenHandler tokenHandler = new(); - - public TicketValidatorTests() => File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); - - public void Dispose() + public sealed class TicketValidatorTests : IDisposable { - rsa.Dispose(); - File.Delete(publicKeyPath); - } + private const string Issuer = "musify-webapi"; + private const string Audience = "media-gateway"; - private TicketValidator CreateValidator() => new(new StreamTicketValidationConfiguration - { - PublicKeyPath = publicKeyPath, - Audience = Audience, - Issuer = Issuer - }); + private readonly string publicKeyPath = Path.GetTempFileName(); + private readonly RSA rsa = RSA.Create(2048); + private readonly JsonWebTokenHandler tokenHandler = new(); - private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience, - DateTime? expires = null, DateTime? notBefore = null, long? maxBytes = null) - { - var claims = new Dictionary { ["prefix"] = prefix }; - if (maxBytes != null) - claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); + public TicketValidatorTests() => File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); - var descriptor = new SecurityTokenDescriptor + public void Dispose() { - Issuer = issuer, - Audience = audience, - NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1), - Expires = expires ?? DateTime.UtcNow.AddMinutes(5), - Claims = claims, - SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), - }; - return tokenHandler.CreateToken(descriptor); - } + rsa.Dispose(); + File.Delete(publicKeyPath); + } - [Fact] - public async Task ValidateTicketAsync_ValidToken_ReturnsThePrefixClaim() - { - var token = IssueToken(prefix: "Tracks/ProcessedAudios/xyz/"); - - var result = await CreateValidator().ValidateTicketAsync(token); - - Assert.True(result.IsValid); - Assert.Equal("Tracks/ProcessedAudios/xyz/", result.Prefix); - Assert.Null(result.MaxBytes); - } - - [Fact] - public async Task ValidateTicketAsync_TokenWithMaxBytesClaim_ReturnsIt() - { - var token = IssueToken(maxBytes: 480_000); - - var result = await CreateValidator().ValidateTicketAsync(token); - - Assert.True(result.IsValid); - Assert.Equal(480_000, result.MaxBytes); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task ValidateTicketAsync_BlankToken_ReturnsInvalid(string? token) - { - var result = await CreateValidator().ValidateTicketAsync(token); - - Assert.False(result.IsValid); - Assert.Equal(string.Empty, result.Prefix); - } - - [Fact] - public async Task ValidateTicketAsync_MalformedToken_ReturnsInvalid() - { - var result = await CreateValidator().ValidateTicketAsync("not-a-real-jwt"); - - Assert.False(result.IsValid); - } - - [Fact] - public async Task ValidateTicketAsync_ExpiredToken_ReturnsInvalid() - { - var token = IssueToken(expires: DateTime.UtcNow.AddMinutes(-40), notBefore: DateTime.UtcNow.AddMinutes(-50)); - - var result = await CreateValidator().ValidateTicketAsync(token); - - Assert.False(result.IsValid); - } - - [Fact] - public async Task ValidateTicketAsync_WrongIssuer_ReturnsInvalid() - { - var token = IssueToken(issuer: "someone-else"); - - var result = await CreateValidator().ValidateTicketAsync(token); - - Assert.False(result.IsValid); - } - - [Fact] - public async Task ValidateTicketAsync_WrongAudience_ReturnsInvalid() - { - var token = IssueToken(audience: "someone-else"); - - var result = await CreateValidator().ValidateTicketAsync(token); - - Assert.False(result.IsValid); - } - - [Fact] - public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsInvalid() - { - using var otherKey = RSA.Create(2048); - var descriptor = new SecurityTokenDescriptor + private TicketValidator CreateValidator() => new(new StreamTicketValidationConfiguration { - Issuer = Issuer, + PublicKeyPath = publicKeyPath, Audience = Audience, - NotBefore = DateTime.UtcNow.AddMinutes(-1), - Expires = DateTime.UtcNow.AddMinutes(5), - Claims = new Dictionary { ["prefix"] = "Tracks/ProcessedAudios/abc/" }, - SigningCredentials = new SigningCredentials(new RsaSecurityKey(otherKey), SecurityAlgorithms.RsaSha256), - }; - var token = tokenHandler.CreateToken(descriptor); + Issuer = Issuer + }); - var result = await CreateValidator().ValidateTicketAsync(token); - - Assert.False(result.IsValid); - } - - [Fact] - public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsInvalid() - { - var descriptor = new SecurityTokenDescriptor + private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience, + DateTime? expires = null, DateTime? notBefore = null, long? maxBytes = null) { - Issuer = Issuer, - Audience = Audience, - NotBefore = DateTime.UtcNow.AddMinutes(-1), - Expires = DateTime.UtcNow.AddMinutes(5), - SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), - }; - var token = tokenHandler.CreateToken(descriptor); + var claims = new Dictionary { ["prefix"] = prefix }; + if (maxBytes != null) + claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); - var result = await CreateValidator().ValidateTicketAsync(token); + var descriptor = new SecurityTokenDescriptor + { + Issuer = issuer, + Audience = audience, + NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1), + Expires = expires ?? DateTime.UtcNow.AddMinutes(5), + Claims = claims, + SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), + }; + return tokenHandler.CreateToken(descriptor); + } - Assert.False(result.IsValid); + [Fact] + public async Task ValidateTicketAsync_ValidToken_ReturnsThePrefixClaim() + { + var token = IssueToken(prefix: "Tracks/ProcessedAudios/xyz/"); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.True(result.IsValid); + Assert.Equal("Tracks/ProcessedAudios/xyz/", result.Prefix); + Assert.Null(result.MaxBytes); + } + + [Fact] + public async Task ValidateTicketAsync_TokenWithMaxBytesClaim_ReturnsIt() + { + var token = IssueToken(maxBytes: 480_000); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.True(result.IsValid); + Assert.Equal(480_000, result.MaxBytes); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ValidateTicketAsync_BlankToken_ReturnsInvalid(string? token) + { + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + Assert.Equal(string.Empty, result.Prefix); + } + + [Fact] + public async Task ValidateTicketAsync_MalformedToken_ReturnsInvalid() + { + var result = await CreateValidator().ValidateTicketAsync("not-a-real-jwt"); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task ValidateTicketAsync_ExpiredToken_ReturnsInvalid() + { + var token = IssueToken(expires: DateTime.UtcNow.AddMinutes(-40), notBefore: DateTime.UtcNow.AddMinutes(-50)); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task ValidateTicketAsync_WrongIssuer_ReturnsInvalid() + { + var token = IssueToken(issuer: "someone-else"); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task ValidateTicketAsync_WrongAudience_ReturnsInvalid() + { + var token = IssueToken(audience: "someone-else"); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsInvalid() + { + using var otherKey = RSA.Create(2048); + var descriptor = new SecurityTokenDescriptor + { + Issuer = Issuer, + Audience = Audience, + NotBefore = DateTime.UtcNow.AddMinutes(-1), + Expires = DateTime.UtcNow.AddMinutes(5), + Claims = new Dictionary { ["prefix"] = "Tracks/ProcessedAudios/abc/" }, + SigningCredentials = new SigningCredentials(new RsaSecurityKey(otherKey), SecurityAlgorithms.RsaSha256), + }; + var token = tokenHandler.CreateToken(descriptor); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsInvalid() + { + var descriptor = new SecurityTokenDescriptor + { + Issuer = Issuer, + Audience = Audience, + NotBefore = DateTime.UtcNow.AddMinutes(-1), + Expires = DateTime.UtcNow.AddMinutes(5), + SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), + }; + var token = tokenHandler.CreateToken(descriptor); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + } } } diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs index 96fe153..248e480 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs @@ -9,244 +9,245 @@ using Musify.StreamingGateway.Configuration; using Musify.StreamingGateway.Middleware; using Xunit; -namespace Musify.StreamingGateway.Tests.Middleware; - -public sealed class TicketValidationMiddlewareTests : IDisposable +namespace Musify.StreamingGateway.Tests.Middleware { - private const string Prefix = "Tracks/ProcessedAudios/abc"; - - private readonly string publicKeyPath = Path.GetTempFileName(); - private readonly RSA rsa = RSA.Create(2048); - private readonly StreamTicketValidationConfiguration options; - - public TicketValidationMiddlewareTests() + public sealed class TicketValidationMiddlewareTests : IDisposable { - File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); - options = new StreamTicketValidationConfiguration + private const string Prefix = "Tracks/ProcessedAudios/abc"; + + private readonly string publicKeyPath = Path.GetTempFileName(); + private readonly RSA rsa = RSA.Create(2048); + private readonly StreamTicketValidationConfiguration options; + + public TicketValidationMiddlewareTests() { - PublicKeyPath = publicKeyPath, - Audience = "media-gateway", - Issuer = "musify-webapi", - }; - } + File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); + options = new StreamTicketValidationConfiguration + { + PublicKeyPath = publicKeyPath, + Audience = "media-gateway", + Issuer = "musify-webapi", + }; + } - public void Dispose() - { - rsa.Dispose(); - File.Delete(publicKeyPath); - } - - private string IssueValidToken(string prefix = Prefix, long? maxBytes = null) - { - var claims = new Dictionary { ["prefix"] = prefix }; - if (maxBytes != null) - claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); - - return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + public void Dispose() { - Issuer = options.Issuer, - Audience = options.Audience, - NotBefore = DateTime.UtcNow.AddMinutes(-1), - Expires = DateTime.UtcNow.AddMinutes(5), - Claims = claims, - SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), - }); - } + rsa.Dispose(); + File.Delete(publicKeyPath); + } - private (TicketValidationMiddleware Middleware, Func WasNextCalled) CreateMiddleware() - { - var nextCalled = false; - RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; - var validator = new TicketValidator(options); - var middleware = new TicketValidationMiddleware(next, validator, options, NullLogger.Instance); - return (middleware, () => nextCalled); - } + private string IssueValidToken(string prefix = Prefix, long? maxBytes = null) + { + var claims = new Dictionary { ["prefix"] = prefix }; + if (maxBytes != null) + claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); - [Fact] - public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = "/health"; + return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + { + Issuer = options.Issuer, + Audience = options.Audience, + NotBefore = DateTime.UtcNow.AddMinutes(-1), + Expires = DateTime.UtcNow.AddMinutes(5), + Claims = claims, + SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), + }); + } - await middleware.InvokeAsync(context); + private (TicketValidationMiddleware Middleware, Func WasNextCalled) CreateMiddleware() + { + var nextCalled = false; + RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; + var validator = new TicketValidator(options); + var middleware = new TicketValidationMiddleware(next, validator, options, NullLogger.Instance); + return (middleware, () => nextCalled); + } - Assert.True(wasNextCalled()); - Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); - } + [Fact] + public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = "/health"; - [Fact] - public async Task InvokeAsync_NoTicket_ReturnsUnauthorized() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.True(wasNextCalled()); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); - } + [Fact] + public async Task InvokeAsync_NoTicket_ReturnsUnauthorized() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; - [Fact] - public async Task InvokeAsync_ValidTicketAsQueryParameter_ForTheRequestedObject_PassesThroughAndStripsTheTicket() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken()}&keep=1"); + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } - Assert.True(wasNextCalled()); - Assert.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?')); - } + [Fact] + public async Task InvokeAsync_ValidTicketAsQueryParameter_ForTheRequestedObject_PassesThroughAndStripsTheTicket() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}&keep=1"); - [Fact] - public async Task InvokeAsync_ValidTicketAsHeader_PassesThrough() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; - context.Request.Headers["X-Stream-Ticket"] = IssueValidToken(); + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.True(wasNextCalled()); + Assert.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?')); + } - Assert.True(wasNextCalled()); - } + [Fact] + public async Task InvokeAsync_ValidTicketAsHeader_PassesThrough() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.Headers["X-Stream-Ticket"] = IssueValidToken(); - [Fact] - public async Task InvokeAsync_ObjectKeyOutsideAuthorizedPrefix_ReturnsForbidden() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = "/media/Tracks/ProcessedAudios/someone-elses-track/audio.m4a"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.True(wasNextCalled()); + } - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); - } + [Fact] + public async Task InvokeAsync_ObjectKeyOutsideAuthorizedPrefix_ReturnsForbidden() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = "/media/Tracks/ProcessedAudios/someone-elses-track/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); - [Theory] - [InlineData($"/media/{Prefix}/../../etc/passwd")] - [InlineData($"/media/{Prefix}/./audio.m4a")] - public async Task InvokeAsync_PathTraversalSegment_ReturnsBadRequest(string path) - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = path; - context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); + } - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); - } + [Theory] + [InlineData($"/media/{Prefix}/../../etc/passwd")] + [InlineData($"/media/{Prefix}/./audio.m4a")] + public async Task InvokeAsync_PathTraversalSegment_ReturnsBadRequest(string path) + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = path; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); - [Fact] - public async Task InvokeAsync_TicketPrefixExactlyMatchesTheRequestedObject_PassesThrough() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + } - Assert.True(wasNextCalled()); - } + [Fact] + public async Task InvokeAsync_TicketPrefixExactlyMatchesTheRequestedObject_PassesThrough() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); - [Fact] - public async Task InvokeAsync_TicketWithMaxBytes_NoRangeRequested_SynthesizesABoundedRange() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.True(wasNextCalled()); + } - Assert.True(wasNextCalled()); - Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString()); - } + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_NoRangeRequested_SynthesizesABoundedRange() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); - [Fact] - public async Task InvokeAsync_TicketWithMaxBytes_RangeWithinCap_ClampsTheEndOnly() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); - context.Request.Headers.Range = "bytes=1000-"; + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.True(wasNextCalled()); + Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString()); + } - Assert.True(wasNextCalled()); - Assert.Equal("bytes=1000-479999", context.Request.Headers.Range.ToString()); - } + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_RangeWithinCap_ClampsTheEndOnly() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + context.Request.Headers.Range = "bytes=1000-"; - [Fact] - public async Task InvokeAsync_TicketWithMaxBytes_RangeStartsAtOrPastTheCap_ReturnsRangeNotSatisfiable() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); - context.Request.Headers.Range = "bytes=480000-"; + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.True(wasNextCalled()); + Assert.Equal("bytes=1000-479999", context.Request.Headers.Range.ToString()); + } - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode); - } + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_RangeStartsAtOrPastTheCap_ReturnsRangeNotSatisfiable() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + context.Request.Headers.Range = "bytes=480000-"; - [Fact] - public async Task InvokeAsync_TicketWithoutMaxBytes_LeavesTheRequestedRangeUntouched() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); - context.Request.Headers.Range = "bytes=1000-2000"; + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode); + } - Assert.True(wasNextCalled()); - Assert.Equal("bytes=1000-2000", context.Request.Headers.Range.ToString()); - } + [Fact] + public async Task InvokeAsync_TicketWithoutMaxBytes_LeavesTheRequestedRangeUntouched() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); + context.Request.Headers.Range = "bytes=1000-2000"; - [Theory] - [InlineData("bytes=0-10,20-30")] - [InlineData("garbage")] - [InlineData("items=0-10")] - public async Task InvokeAsync_TicketWithMaxBytes_MultiOrInvalidRange_ReplacesItWithBoundedRange(string requestedRange) - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); - context.Request.Headers.Range = requestedRange; + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.True(wasNextCalled()); + Assert.Equal("bytes=1000-2000", context.Request.Headers.Range.ToString()); + } - Assert.True(wasNextCalled()); - Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString()); - } + [Theory] + [InlineData("bytes=0-10,20-30")] + [InlineData("garbage")] + [InlineData("items=0-10")] + public async Task InvokeAsync_TicketWithMaxBytes_MultiOrInvalidRange_ReplacesItWithBoundedRange(string requestedRange) + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + context.Request.Headers.Range = requestedRange; - [Fact] - public async Task InvokeAsync_TicketWithMaxBytes_SuffixRange_ReturnsRangeNotSatisfiable() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); - context.Request.Headers.Range = "bytes=-500"; + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.True(wasNextCalled()); + Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString()); + } - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode); + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_SuffixRange_ReturnsRangeNotSatisfiable() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + context.Request.Headers.Range = "bytes=-500"; + + await middleware.InvokeAsync(context); + + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode); + } } } diff --git a/backend/docs/backend-refactor-handoff.md b/backend/docs/backend-refactor-handoff.md new file mode 100644 index 0000000..4ab7eea --- /dev/null +++ b/backend/docs/backend-refactor-handoff.md @@ -0,0 +1,235 @@ +# Handoff: backend refactor, parts P12 to P15 + +> For the next AI. Read this file first, then `backend/docs/backend-refactor-plan.md` +> (section A, B, and the parts P12–P15, plus the Log in section D). Both files are +> **untracked on purpose**: never commit them. + +## 1. Where things stand + +- Worktree: `C:\Users\XShin\OneDrive\Documentos\Musify\MusifyM\.claude\worktrees\backend-refactor-plan-9b4465` +- Branch: `claude/backend-refactor-plan-9b4465` (base `master` at `87c3f97`). Nothing is pushed. +- Done and committed: **P0 to P11** (one commit per part, see `git log --oneline`). +- Left: **P12** (API layer), **P13** (Worker and Gateway hosts), **P14** (tests), **P15** (final sweep and docs). +- State at the last commit (`405df08`): build with 0 warnings, 365 tests green + (Infrastructure tests need Docker), `npm run check` in `web-player/` clean. +- The Log (section D of the plan) already has notes for P0–P11 and is the source of truth + for decisions taken. Update it (checkbox, date, notes) for every part you finish. + +### Deviations from the plan already taken (do not undo) + +- `IOwnedEntity` lives in `Domain/Abstractions` and has no `Id`; `IHasLifeCycle` provides it. + `FindOwnedAsync` requires both and treats non-active entities as NotFound. +- List query records keep `int PageNumber, int PageSize`; the handler builds a `PageRequest`. + The API binds them through `Api/Models/PageQuery` (camelCase names) + `PageQueryValidator`. +- Events and `Publish*Arguments` carry `ImageSizes Sizes`. +- Cover endpoint helper `CoverEndpoint.Stream` already exists (this was P12.2). +- Mix titles were replaced by `MixKind`; the web-player renders the text (`src/lib/data/mixes.ts`). +- The two migrations planned in P4 were merged into `DropUploadTableAndTrackAudioRetryColumns`. +- Adding a duplicate track to a playlist or album is now a silent success (bulk endpoints). +- Hangfire cron has 1-minute resolution, so job intervals in seconds are rounded up to minutes. +- Pending inside P11: log-and-rethrow still present in `MassTransit/Activities/Files/*` + (Copy, Download, Remove, Transfer, Upload). Clean it in P15 or when touching them. + +## 2. Rules the user gave (follow them exactly) + +1. **Language of the conversation: Spanish.** Talk to the user in Spanish. +2. **Commit messages: English, only about the code that changed.** Never mention the plan, + part numbers (P0, P1...), the Log, or "step X". One commit per part, imperative mood, + e.g. `Simplify infrastructure services and move all recurring jobs to Hangfire`. + (This is also saved in the memory file `feedback_commit_messages.md`.) +3. **Never include the plan documents in commits.** `backend/docs/backend-refactor-plan.md` + and `backend/docs/backend-refactor-handoff.md` stay untracked. Also never commit the + temporary test `backend/Tests/Musify.Api.Tests/Endpoints/ZzOpenApiDumpTests.cs`. + Stage with `git add backend web-player` and then + `git reset -q backend/docs backend/Tests/Musify.Api.Tests/Endpoints/ZzOpenApiDumpTests.cs`. +4. **Do the plan in parts, one part per commit, in order.** Do not mix parts. Never mix + formatting with behaviour. +5. **When the user says to stop at a point, stop there** (the last message was "para en esta + parte cuando termines", so work stopped after P11). Only continue when asked. +6. Follow the plan's own rules: read section A first; create a shared helper from section B + yourself if it does not exist yet; keep the Log and the findings index (section E) up to date; + a contract change needs the web-player regeneration (section 4 below); an EF model change + needs a migration and `TestDatabase` kept in sync; a MassTransit message change must be + noted in the Log ("drain queues before deploying"). +7. Pronouns: use they/them for anyone whose pronouns are not stated. +8. Outward-facing or hard-to-reverse actions (push, PR, deleting things) need explicit approval. + Nothing has been pushed; do not push or open a PR unless asked. +9. Report faithfully: if a test fails or something was skipped or unverifiable + (e.g. manual end-to-end runs), say so in the Log and in the final message. + +## 3. Coding conventions (from the plan, section B.2, keep applying them) + +- File-scoped namespaces, usings outside, sorted (`.editorconfig` enforces it; the build treats + warnings as errors, including unused usings IDE0005). +- Private fields camelCase without underscore; constants PascalCase; explicit accessibility. +- Application: `/.cs` holds record + handler; responses in `Responses/`. +- Errors via `AppErrors` (`NotFound`, `Forbidden` = 403 for "not owner", `Conflict`); + `Unauthorized` (401) only for "not signed in". Success value is `Result.Success`. +- No try/catch around `SaveChangesAsync`/`PublishAsync` in handlers (outbox: publish then save + is atomic). Infrastructure either handles or rethrows, never log-and-rethrow. +- Handlers that cannot fail return plain types; lists are paginated, ordered with `Id` tiebreak, + public reads return only `LifeCycleStatus.Active`. +- Text: trim stored names; normalized columns use `TextNormalizer.Normalize`. +- `ArgumentNullException.ThrowIfNull(x)` without `nameof`. +- User-facing strings in English (the web-player owns translation). +- Naming: `PlayList` everywhere; config POCOs end in `Configuration`; injected services are + named after their type (`storageService`). + +## 4. Practical procedures (learned the hard way) + +Run everything from `backend/` unless stated. + +### Build, format fixes, tests + +```bash +dotnet build Musify.slnx +dotnet test Musify.slnx +``` + +- Do **not** chain `dotnet test ... | grep && commit`: a failing run can look successful. + Read the totals (`total:`, `succeeded:`) before committing. +- Infrastructure and Api tests need Docker Desktop running. If `docker info` fails, start + `C:\Users\XShin\AppData\Local\Programs\DockerDesktop\Docker Desktop.exe` and wait. +- Unused-using errors (IDE0005) are common after edits. A helper script removes them: + `python /fixusings.py` (loops `dotnet build`, deletes the flagged using lines). + If the scratchpad is gone, do it by hand or with `dotnet format`. + +### Editing files from the shell (important) + +- The Bash tool **fails to parse** many commands that combine several heredocs, inline + `python - <<'EOF'` with backslashes/quotes, or big C# blocks ("unexpected EOF while looking + for matching `'`"). When that happens nothing runs. Use the **Write tool** for files and put + Python edit scripts in a file (in the scratchpad) and run `python that_file.py`. +- Working tree files use LF while git has autocrlf on; warnings like "LF will be replaced by + CRLF" are noise. Redirect stderr (`2>/dev/null`) on `git add/commit`. Python edit helpers + should read/write with `newline=''` and preserve CRLF if present. + +### EF migrations + +```bash +export Database__ConnectionString="Host=localhost;Database=x;Username=x;Password=x" +dotnet ef migrations add --project Core/Musify.Infrastructure --startup-project Core/Musify.Infrastructure +``` + +- `dotnet ef migrations remove` needs a DB connection and fails offline: delete the migration + files by hand and `git checkout` the snapshot, then re-add. +- To verify a model refactor keeps the schema: add a migration named `Check`, confirm `Up` + is empty, delete its two files (keep snapshot changes if they only rename types). +- Hand-edit migrations when data must be preserved (see `MixKind`, `PlayListTrackUnique`). +- `Application.Tests/TestSupport/TestDatabase.cs` mirrors part of `Database.OnModelCreating` + (P14.3 will extract `ModelConfiguration.Apply`). + +### Web-player contract regeneration (when an HTTP contract changes) + +1. `web-player/node_modules` is a junction to the main checkout's `node_modules` + (git-ignored). If missing: + `cmd //c "mklink /J web-player\node_modules ..\..\..\web-player\node_modules"` from the worktree root. +2. The OpenAPI document is dumped by the temporary test `ZzOpenApiDumpTests` (untracked; it + returns early unless env `OPENAPI_OUT` is set and runs the API in Development). Script used: + +```bash +cd backend +export ASPNETCORE_ENVIRONMENT=Development OPENAPI_OUT="/openapi.json" +dotnet test Tests/Musify.Api.Tests --filter "FullyQualifiedName~ZzOpenApiDump" +cd ../web-player +npx -y openapi-typescript@7 "/openapi.json" -o src/lib/api/schema.d.ts +npm run check +``` + + If the test file is gone, recreate it: it fetches `/openapi/v1.json` through + `fixture.CreateAnonymousClient()` and writes it to `OPENAPI_OUT`. + P12.1 changes `WebApplication.CreateBuilder(args)`; keep the dump working afterwards. +3. Fix web-player call sites, run `npx prettier --write` on touched files, then `npm run check`. +4. Delete `ZzOpenApiDumpTests.cs` at the very end (P15). + +## 5. What is left, part by part + +### P12: API layer (plan section C, P12) + +1. `Program.cs`: `WebApplication.CreateBuilder(args)`, sorted usings, CORS constant at top. +2. Cover endpoints: **already done** with `CoverEndpoint.Stream`. +3. Routes: resource id is `{id}`, sub-resources `{trackId}`; Guid routes use `:guid`, + user ids `:long`. Playlist routes keep `/playlists`. (Contract change: regenerate.) +4. Every endpoint returns `IResult` through `ToHttpResult` (some still return raw types, + e.g. `GetListeningHistory`, `IsFollowing`); use `Results.Ok` where the handler cannot fail. +5. DTOs: drop the `New...` prefix (`UpdateAlbumRequest(Title, Description, ReleaseYear, + PictureIntentId)`; same for playlists). Decision recommended in the plan: rename. +6. Validators: merge the album/playlist picture upload DTOs into one + `PictureUploadRequest` and one validator (the responses are already merged). +7. `OpenApiOptionsSetup` and `ScalarOptionsSetup` share the `SecuritySchemeId` constant. +8. `.WithSummary(...)` strings in sentence case, no trailing period. (Current ones are Title Case.) +9. `JwtBearerOptionsSetup`: single-line guard without braces. +10. `Validators/Limits.cs` with every validation limit (playlist name 50, descriptions 256, + titles 200, user name 48, release year range). List them in the Log for the web-player + plan (P7.1). Database column length wins on disagreement. + +Coordination table (section A.4): P3.1 and P8.1 of the web-player plan are unblocked by P7; +P8.4 (bulk add) by P9; P7.1 (shared limits) by P12.10. Tick them in the web-player plan's Log too +(`web-player/docs/frontend-audit-plan.md`, also untracked). + +### P13: Worker and Gateway hosts + +1. Worker `Program.cs`: call `AddApplication(configuration)` instead of registering Application + options by hand (Album config is missing today), add `AddObservability`, remove + `ValidateOnBuild = false` and make the container validate. Register the extra POCO configs + the handlers need (stream tickets, gateway config) rather than disabling validation. + Then `AddUploadIntentConfiguration` (kept only for the Worker) can go. +2. Worker `appsettings.json`: complete and consistent with the Api for shared sections + (add `Album`; keys were renamed in P5), remove stale ones. +3. Gateway: keep option registration local and self-contained; field naming without + underscores (`TicketValidator` uses `_options`, `_rsa`, ...; the middleware field that + shadowed a constructor parameter is already gone); replace the default bucket in the + YARP transform of `appsettings.json` with a placeholder like `/buckets/CHANGE_ME/{**rest}` + (compose overrides it) or document it in `docs/streaming.md`. + Check `deploy/` when renaming config keys. + +### P14: Tests + +1. Add tests for what has none: MassTransit activities (generic lifecycle and picture-update + ones with the SQLite `TestDatabase`), `ProcessingSlipFaultConsumer` (every process kind), + routing slip builders (activity order and arguments), saga state machines with MassTransit's + test harness (happy path, failure, missing instance on update; sagas already use + `OnMissingInstance(Discard)`), `RecurringJobsRegistrar.EveryCron` (internal, may need + `InternalsVisibleTo`). +2. Endpoint tests for visibility and ownership if not already covered (P1, P3 added several). +3. Extract the owned-type and index configuration of `Database` into + `ModelConfiguration.Apply(ModelBuilder)` in Infrastructure and call it from `Database` and + `TestDatabase` (`Application.Tests` then references Infrastructure; note it in the Log). + +### P15: Final sweep and documentation + +1. Recompute the metrics of section A.5 (script in the plan) and fill D.1 (baseline was + 12,687 non-migration, non-test `.cs` lines; targets in A.5). Also record the MassTransit + line count (3,547 to about 2,530) and the Application line count. +2. Grep for leftovers: `Error.Unauthorized(`, `StaticPagedList`, `new ImageSize(` (only in + `PictureSizes.ToImageSizes`), `ThrowIfNull(.*nameof`, `#pragma warning disable CS8618`, + try blocks around `SaveChangesAsync` in `Application/`, `serviceDescriptors`, + `storageHandler`, `PlaylistVisibility`, log-and-rethrow in `MassTransit/Activities/Files`. +3. The analyzer rules downgraded to `suggestion` in `.editorconfig` in P0 (CA1848, CA1873, + CA1305, CA1707, CA1859, CA1716, CA1000, CA1725, CA1816, CA1822, CA1852, CA1068, CA1711, + CA1861) should be revisited: fix the cheap ones and remove their overrides. +4. Run `dotnet format Musify.slnx` and check the diff is whitespace only (some test files have a + stray blank line before the closing brace). +5. Update `docs/architecture.md`, `docs/media-processing.md`, `docs/projects.md`, + `docs/storage.md`, `docs/streaming.md` with the new names (jobs, entities, routes, config + keys such as `ParentFolder`, `Temp*`, activity/queue names, `MixKind`). +6. Add a short `backend/CLAUDE.md` with the conventions of section 3 above (this one **is** + committed). +7. Delete `ZzOpenApiDumpTests.cs`, keep the plan (section E rows should all point to a checked + part in D), and remove the `web-player/node_modules` junction only if desired (it is ignored). + +## 6. Deployment notes to keep in the Log + +Queues must be drained before deploying because these message contracts changed: +processing-slip process kinds and variables (P2), `ImageSizes` in events and arguments (P8), +new lifecycle/delete/update-picture activities and arguments (P11), `DeleteAlbumEvent` (P3). +Migrations added so far: `PlayListTrackUnique`, `DropUploadTableAndTrackAudioRetryColumns`, +`DropUserHasTrack`, `MixKind`. Old bucket objects under the `temporal/` prefix (from before the +`Temp*` config fix) may need a one-off clean-up. + +## 7. Suggested first steps for the next session + +1. `git log --oneline -14` and `git status --short` (expect only the two untracked docs and + `ZzOpenApiDumpTests.cs`). +2. Run build + tests to confirm the green baseline. +3. Start P12, commit, update the Log, then continue only if the user has not asked to stop. diff --git a/backend/docs/backend-refactor-plan.md b/backend/docs/backend-refactor-plan.md new file mode 100644 index 0000000..98b53a3 --- /dev/null +++ b/backend/docs/backend-refactor-plan.md @@ -0,0 +1,1108 @@ +# Plan: Backend hardening, cleanup and deduplication + +> Work plan for agents/AI with limited context (~128k). Based on a full read of +> **every** non-migration `.cs`, `.csproj` and `appsettings*.json` file under `backend/` +> (2026-09-24, branch `master` at `87c3f97`), plus the web-player call sites of the API. + +## How to use this document + +1. **Always read section A (Common context).** It's short and every part assumes it. +2. Pick **one part** (P0 through P15). Each part lists: objective, prerequisites, files to + read, tasks, and closing criteria. +3. One part equals one commit/PR. Don't mix parts. P0 is a pure formatting commit: never + mix formatting with behaviour changes. +4. If a part needs a shared helper from section B and it **doesn't exist yet**, create it + yourself exactly as specified in B, so any part can create it identically. +5. When you finish a part, check its box in section D (Log) and fill in the date and notes. +6. Section E maps every audit finding to the part that fixes it. Nothing in E may be left + without a part; if you discover a new finding, add it to E and to the right part. + +Recommended order (arrows are **soft** dependencies, see point 4): + +``` +P0 (format + guardrails) + ├→ P1 (security) → P2 (pipeline bugs) → P3 (query/API bugs) + └→ P4 (dead code) + ↓ +P5 (config) → P6 (domain) → P7 (application helpers) → P8 (pictures feature) + ↘ P9 (responsibilities) +P10 (infra services) → P11 (MassTransit) → P12 (API layer) → P13 (Gateway/Worker) + → P14 (tests) → P15 (final sweep) +``` + +P1 must land before anything else that touches endpoints: it closes real data leaks. + +--- + +## A. Common context (always read) + +### A.1 Solution layout + +| Project | Path | Role | +|---|---|---| +| Domain | `Core/Musify.Domain` | Entities (EF data-annotated), value objects, enums | +| Application | `Core/Musify.Application` | Mediator commands/queries (`/.cs` = record + handler), responses, contracts (`IDatabase`, `IStorageService`, …), configuration POCOs | +| Infrastructure | `Core/Musify.Infrastructure` | EF `Database`, S3 `StorageService`, ffmpeg, ImageSharp, MassTransit (activities, routing slips, sagas, consumers), jobs | +| Api | `Hosts/Musify.Api` | Minimal API endpoints, DTOs, FluentValidation validators, JWT auth | +| StreamingGateway | `Hosts/Musify.StreamingGateway` | YARP proxy to SeaweedFS with signed stream tickets | +| Worker | `Hosts/Musify.Worker` | MassTransit consumers + Hangfire + hosted jobs | +| Tests | `Tests/*` | xUnit v3. Application tests use SQLite in-memory (`TestDatabase`), Infrastructure tests use Testcontainers (need Docker) | + +Libraries: Mediator (source generator, scoped), ErrorOr, EF Core 10 + Npgsql, MassTransit 8 +(RabbitMQ, EF outbox, courier routing slips, saga state machines), Hangfire, FluentValidation, +X.PagedList (to be removed in P7), YARP. + +### A.2 Verifying each part + +From `backend/`: + +```bash +dotnet build Musify.slnx +``` + +```bash +dotnet test Musify.slnx +``` + +- The build must stay at **0 warnings** (after P0, warnings are errors). +- `Infrastructure.Tests` need Docker running. If Docker isn't available, say so in the Log; + don't skip silently. +- A part that changes an **HTTP contract** (route, request/response shape, status code) must + also: run the API locally, regenerate the web-player client (`npm run gen:api` in + `web-player/`, needs the API on `http://localhost:5111`), fix the web-player call sites, and + run `npm run check` in `web-player/`. List the contract changes in the Log. +- A part that changes the **EF model** must add a migration from `backend/`: + +```bash +dotnet ef migrations add --project Core/Musify.Infrastructure --startup-project Core/Musify.Infrastructure +``` + + Also update `Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs`, which mirrors part + of the model configuration by hand. +- A part that changes a **MassTransit message contract** (anything in `Application/Events` or + `Infrastructure/MassTransit/Arguments|Logs`) must note in the Log that queues have to be + drained before deploying (in-flight messages with the old shape will fail). + +### A.3 Known gotchas (don't reintroduce them) + +1. **Outbox.** The API uses the EF bus outbox (`UseBusOutbox`). `eventBus.PublishAsync` only + writes to the outbox; the message leaves when `SaveChangesAsync` commits. So "publish, then + save" is correct and atomic. Don't wrap the publish in its own try/catch. +2. **Worker DI.** `AddMediator()` registers **every** handler, including ones whose dependencies + the Worker doesn't have. Today that's hidden by `ValidateOnBuild = false`. P13 fixes it; until + then, don't add handler dependencies that the Worker can't resolve if the Worker sends that + command. +3. **Private IDs as strings.** `long` user ids are serialized as strings (JS precision). Keep that + when moving the converter (P9). +4. **Track pictures vs album/playlist pictures.** New tracks point at shared *preset* images until + processed; `DeleteTrackRoutingSlipBuilder` only deletes resized files when + `Pictures.IsProcessed`, so presets are never deleted. Keep that guard. +5. **Deploy overrides.** `deploy/compose.yml` overrides config via env vars (`Section__Key`). When + renaming a config key, grep `deploy/` too. +6. **The web-player is the only client.** Endpoints it doesn't call (see E.2) can change freely; + the others need the regeneration step in A.2. + +### A.4 Coordination with the web-player plan + +`web-player/docs/frontend-audit-plan.md` has parts marked 🔗 that need this backend. They are +scheduled here so both plans converge: + +| Web-player part | Needs from the backend | Done in | +|---|---|---| +| P3.1 playlist permissions | `ownerUserId` in `PlayListApplicationResponse` | P7.9 | +| P8.1 performance | `trackCount` and `durationSeconds` in `PlayListApplicationResponse` | P7.9 | +| P8.4 add album to playlist | bulk insert: add many tracks to a playlist in one request | P9.4 | +| P7.1 shared `LIMITS` | the real validator limits (playlist description is 256 here, track title 200) | P12.10 | + +When one of these lands, tick it in **both** Logs. + +### A.5 Measured baseline (2026-09-24) + +| Metric | Value | Target | +|---|---|---| +| Non-migration `.cs` lines (excluding tests) | 12,687 | −25–30 % | +| Build warnings | 0 (no analyzers, no style rules) | 0 with analyzers + style enforced | +| Files with block namespace / file-scoped | 249 / 50 | 0 / all | +| Files with UTF-8 BOM | 31 (2 with a BOM mid-file) | 0 | +| Files without final newline | 61 | 0 | +| Stray spaces (`} );`, `nameof(X) )]`, `{Id} )`) | 43 | 0 | +| `ThrowIfNull(x, nameof(x))` | 19 | 0 | +| `new ImageSize(` | 15 | 3 (inside `PictureSizes`) | +| Cover-ids projection copies | 9 | 1 per entity | +| `ListeningHistories.Count(l => l.IsCounted)` copies | 9 | 1 | +| `new StaticPagedList` | 7 | 0 | +| `Error.Unauthorized()` used for "not owner" | 10 | 0 | + +Recompute with the script in P15 and record in D.1. + +--- + +## B. Shared specs (single source of truth) + +### B.1 Style guardrails + +#### B.1.1 `backend/.editorconfig` (create in P0) + +```ini +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +[*.{csproj,props,targets,slnx,xml}] +indent_size = 2 + +[*.json] +indent_size = 4 + +[**/Migrations/**.cs] +generated_code = true + +[*.cs] +indent_size = 4 + +csharp_style_namespace_declarations = file_scoped:warning +csharp_using_directive_placement = outside_namespace:warning +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +dotnet_diagnostic.IDE0005.severity = warning + +dotnet_style_require_accessibility_modifiers = always:warning +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion +csharp_prefer_braces = when_multiline:warning +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion + +dotnet_naming_rule.private_fields.symbols = private_fields +dotnet_naming_rule.private_fields.style = camel_case +dotnet_naming_rule.private_fields.severity = warning +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_symbols.private_fields.required_modifiers = +dotnet_naming_style.camel_case.capitalization = camel_case + +dotnet_naming_rule.private_constants.symbols = private_constants +dotnet_naming_rule.private_constants.style = pascal_case +dotnet_naming_rule.private_constants.severity = warning +dotnet_naming_symbols.private_constants.applicable_kinds = field +dotnet_naming_symbols.private_constants.required_modifiers = const +dotnet_naming_style.pascal_case.capitalization = pascal_case +``` + +Private fields are **camelCase without underscore** (matches primary-constructor parameters, +which are the majority of the code). Constants and `static readonly` are PascalCase. + +#### B.1.2 `backend/Directory.Build.props` (update in P0) + +```xml + + + + net10.0 + enable + enable + latest-recommended + true + true + true + $(NoWarn);CS1591 + + + +``` + +`GenerateDocumentationFile` is only there so IDE0005 (unused usings) is reported on build. +If `latest-recommended` raises rules that would force behaviour changes in P0, set those +specific rules to `suggestion` in `.editorconfig` with a comment naming the part that will fix +them, and remove the override in that part. + +### B.2 Coding conventions (apply in every part you touch) + +1. **File layout.** `Application//.cs` holds the command/query record and its + handler. Responses live in `Application//Responses/`. No response records declared + inside handler files, no mapping methods on commands. +2. **Errors** (via `AppErrors`, B.3.6): + - missing, or exists but not visible to the caller → `NotFound` + - exists but caller isn't the owner → `Forbidden` (403). **Never** `Unauthorized` for that; + `Unauthorized` (401) is only for "not signed in". + - duplicate/state clash → `Conflict`; bad input → `Validation`. + - Every error has a code (`"Album.NotFound"`) and a description. +3. **Success value:** `Result.Success`. +4. **No try/catch around `SaveChangesAsync` or `PublishAsync` in handlers.** Unexpected + exceptions go to the global `UseExceptionHandler`. Catch only what you can turn into a + domain error (e.g. unique-constraint violation → `Conflict`, see B.3.7). +5. **Logging.** Handlers log state changes at `Information` (created/updated/deleted). Not-found + and forbidden aren't logged (the HTTP log already shows them). Infrastructure code **either** + handles an exception **or** rethrows it; never log-and-rethrow. +6. **Queries never write.** If a read needs a side effect, it's a command. +7. **Return types.** A handler that can't fail returns the plain type (`ValueTask`), not + `ErrorOr`. A handler that can fail returns `ErrorOr`. Never return a lazy `IEnumerable`. +8. **Lists** are paginated with `PageRequest` (B.3.4) and have a deterministic order with `Id` as + the final tiebreaker. Public reads only return `LifeCycleStatus.Active` rows. +9. **Text input:** stored names/titles are trimmed; normalized columns use + `TextNormalizer.Normalize` (B.3.5). +10. **Entities:** no `[Required]` on value types, no `[Required]` next to `required` on + non-nullable strings, no initializers equal to the default (`= 0`, `Id = Guid.NewGuid()` in + object initializers). Navigations: `public User Owner { get; set; } = null!;` (no + `#pragma warning disable CS8618`, no `required` on navigations). +11. **Naming:** `PlayList` everywhere (types, variables, route parameters: `{playListId}` → + see P12 for the route style). `…Configuration` for config POCOs. Injected services are named + after their type (`storageService`, not `storageHandler`). +12. **`ArgumentNullException.ThrowIfNull(x)`** without `nameof`. +13. User-facing strings (mix titles, error descriptions) are English; the web-player owns + translation. + +### B.3 Shared helpers (exact paths and APIs) + +#### B.3.1 Pictures configuration (`Application/Configuration/Pictures.cs`) + +Replaces `AlbumRoutes`, `PlayListRoutes`, the picture half of `TrackRoutes`, and the three +`*PicturesSizes` classes. + +```csharp +namespace Musify.Application.Configuration; + +public enum PictureSize { Small, Medium, Large } + +public class PictureRoutes +{ + [Required] public string UploadsFolder { get; set; } = "uploads"; + [Required] public string ParentFolder { get; set; } = string.Empty; + [Required] public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; + [Required] public string SmallPicturesFolder { get; set; } = "SmallPictures"; + [Required] public string MediumPicturesFolder { get; set; } = "MediumPictures"; + [Required] public string LargePicturesFolder { get; set; } = "LargePictures"; + + public string FolderPath(PictureSize size); // ParentFolder/ + public string BuildPicturePath(PictureSize size, string name); // FolderPath(size)/name + public string BuildOriginalPicturePath(long userId, string name); // uploads/{userId}/ParentFolder/OriginalPictures/name + public string BuildTempPath(string tempRootPrefix, long userId, string objectName); +} + +public class PictureSizes +{ + [Range(1, 1024)] public int SmallWidth { get; set; } = 128; + [Range(1, 1024)] public int SmallHeight { get; set; } = 128; + [Range(1, 1024)] public int MediumWidth { get; set; } = 256; + [Range(1, 1024)] public int MediumHeight { get; set; } = 256; + [Range(1, 1024)] public int LargeWidth { get; set; } = 512; + [Range(1, 1024)] public int LargeHeight { get; set; } = 512; + + public ImageSizes ToImageSizes(PictureRoutes routes); +} + +public interface IPictureOwnerConfiguration +{ + PictureRoutes Routes { get; } + PictureSizes PicturesSizes { get; } +} +``` + +- `AlbumConfiguration` and `PlayListConfiguration` become `{ PictureRoutes Routes; PictureSizes PicturesSizes; }` + implementing `IPictureOwnerConfiguration`. +- `TrackConfiguration.Routes` becomes `TrackRoutes : PictureRoutes` adding only the preset and + audio members (`PresetSmallPicture…`, `OriginalAudiosFolder`, `ProcessedAudiosFolder`, + `BuildOriginalAudioPath(userId, name)`, `BuildProcessedAudioPath(folder)`). + `BuildTempAudioPath` is deleted (it was identical to the picture one): use `BuildTempPath`. +- Config keys change: `ParentFolders` → `ParentFolder` (Album/PlayList), `ProcessedAudioFolder` → + `ProcessedAudiosFolder`, `SmallPictureWidth` → `SmallWidth` etc. Update every `appsettings*.json` + and grep `deploy/`. +- The un-prefixed `BuildOriginalPicturePath(string name)` overloads and the `…PicturesPath` + properties that nobody needs after P8 are removed. + +#### B.3.2 `ImageSizes` (`Application/Shared/ImageSize.cs`) + +```csharp +public record ImageSize(string SavePath, int Width, int Height); + +public record ImageSizes(ImageSize Small, ImageSize Medium, ImageSize Large); +``` + +Events and routing-slip arguments carry one `ImageSizes Sizes` instead of three `ImageSize` +parameters (message contract change, see A.2). + +#### B.3.3 `EntityPictures` (`Domain/ValueObjects/EntityPictures.cs`) + +Replaces `AlbumPictures` and `PlayListPictures` (identical today). Same four string properties. +Column names are mapped explicitly in `Database.cs`, so **no migration** is needed; verify with +`dotnet ef migrations add` producing an empty migration, then delete it. + +Also add a factory used by create/update handlers: + +```csharp +public static EntityPictures Pending(string objectName); +``` + +See P3 for why "pending" must not point the resized names at the original file. + +#### B.3.4 Pagination (`Application/Shared/Pagination.cs`) + +Replaces X.PagedList (remove `X.PagedList` and `X.PagedList.EF` packages at the end of P7). + +```csharp +public record PageRequest(int PageNumber = 1, int PageSize = 20) +{ + public const int MaxPageSize = 100; +} + +public record PaginatedResponse( + IReadOnlyList Items, + int PageNumber, + int PageSize, + int PageCount, + int TotalItemCount, + bool HasNextPage, + bool HasPreviousPage); + +public static class PaginationExtensions +{ + public static Task> ToPaginatedAsync( + this IQueryable query, PageRequest page, CancellationToken cancellationToken); +} +``` + +- `ToPaginatedAsync` clamps `PageNumber >= 1` and `1 <= PageSize <= MaxPageSize`, does + `CountAsync` + `Skip/Take` on the **same** filtered query. +- The query passed in must already be ordered and projected (B.3.5). +- The JSON shape of `PaginatedResponse` stays exactly as today (same property names and order). +- API side: endpoints bind `[AsParameters] PageRequest page`; `PageRequestValidator` rejects + `PageNumber < 1` and `PageSize` outside `1..100` with a 400. + +#### B.3.5 Projections and text (`Application//Responses/*Projections.cs`, `Application/Shared/TextNormalizer.cs`) + +```csharp +public static class TrackProjections +{ + public static IQueryable Active(this IQueryable query); + public static IQueryable SelectResponse(this IQueryable query); +} + +public static class AlbumProjections +{ + public const int CoverTrackCount = 4; + public static IQueryable Active(this IQueryable query); + public static IQueryable SelectResponse(this IQueryable query); +} + +public static class PlayListProjections +{ + public const int CoverTrackCount = 4; + public static IQueryable Active(this IQueryable query); + public static IQueryable VisibleTo(this IQueryable query, long? viewerId); + public static IQueryable SelectResponse(this IQueryable query); +} + +public static class TextNormalizer +{ + public static string Normalize(string value); // value.Trim().ToUpperInvariant() +} +``` + +- `SelectResponse` is a single EF-translatable `Select(...)` building the response directly + (owner name, tag list, `ListeningHistories.Count(l => l.IsCounted)`, track count, cover ids). + No `Include`, no anonymous intermediate types, no `FromEntity` in queries. +- Keep `FromEntity` only where a handler already has a tracked entity in memory (create/update). +- `VisibleTo(viewerId)`: `Visibility == Public || OwnerUserId == viewerId`. +- Join-table queries (album tracks, playlist tracks, likes) order the join rows, then + `Select(x => x.Track)` and call `SelectResponse()`. + +#### B.3.6 Errors and ownership (`Application/Shared/AppErrors.cs`, `Application/Shared/OwnershipExtensions.cs`) + +```csharp +public static class AppErrors +{ + public static Error NotFound(string entity, object id); // code ".NotFound" + public static Error Forbidden(string entity, object id); // code ".Forbidden" + public static Error Conflict(string code, string description); +} + +public interface IOwnedEntity +{ + Guid Id { get; } + long OwnerUserId { get; } +} + +public static class OwnershipExtensions +{ + public static Task> FindOwnedAsync( + this DbSet set, Guid id, long userId, CancellationToken cancellationToken) + where T : class, IOwnedEntity; +} +``` + +`Track`, `Album` and `PlayList` implement `IOwnedEntity`. `PlayList.UserId` is renamed to +`OwnerUserId` with `[Column("UserId")]` (no migration). + +#### B.3.7 Unique-violation mapping (`Application/Contracts/IDatabase.cs`) + +```csharp +Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken); +``` + +Implemented in `Database` by catching `DbUpdateException` whose inner exception is +`PostgresException { SqlState: "23505" }` (and the SQLite equivalent in `TestDatabase`). Used by +like, follow, add-to-playlist/album. + +#### B.3.8 Lifecycle (`Domain/Abstractions/IHasLifeCycle.cs`) + +```csharp +public interface IHasLifeCycle +{ + Guid Id { get; } + LifeCycleStatus LifeCycleStatus { get; set; } +} +``` + +Implemented by `Track`, `Album`, `PlayList`. Used by the generic MassTransit activities (P11). + +#### B.3.9 Routing slips (`Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs`) + +Replaces the preamble copied in 10 builders/consumers and `Consumers/EndpointHelper.cs`. + +```csharp +internal static class RoutingSlips +{ + public static RoutingSlipBuilder Create(Guid? correlationId); // new id + CorrelationId variable + clean-up subscription + public static RoutingSlipBuilder AddStep(this RoutingSlipBuilder builder, string name, string executeEndpointName, object arguments); + public static RoutingSlipBuilder TrackFaults(this RoutingSlipBuilder builder, Guid subjectId, string processKind); // SubjectId + ProcessKind + fault subscription +} +``` + +`RoutingSlipVariableNames` moves to namespace `Musify.Infrastructure.MassTransit.RoutingSlip` +(it lives in that folder already). + +--- + +## C. Parts + +### P0: Formatting and guardrails (pure formatting commit) + +**Objective:** make style mechanical so later parts don't argue about it. + +**Tasks** +1. Create `backend/.editorconfig` (B.1.1) and update `Directory.Build.props` (B.1.2). +2. Remove BOMs from all `.cs` files (including the two mid-file ones in + `Application/Events/UpdatePlayListPictureEvent.cs` and `UpdateTrackPictureEvent.cs`). +3. From `backend/`: + +```bash +dotnet format Musify.slnx +``` + + then fix by hand whatever `dotnet format` can't: the 43 stray spaces (`} );`, `} ));`, + `nameof(X) )]`, `(PID: {ProcessId} )`, `prefix: {Prefix} )`, `if (a == null|| …)`), blank + lines right after `{` of a namespace/class, and missing `private` modifiers. +4. Build with warnings as errors. For analyzer rules that need behaviour changes, downgrade them + in `.editorconfig` with a `# fixed in Pn` comment (see B.1.2). + +**Don't:** rename anything, change any logic, touch migrations. + +**Closing criteria:** `git diff --stat` shows only whitespace/namespace/using changes (review +with `git diff -w`); 0 warnings; tests green; metrics in A.5 for namespaces, BOM, final newline, +stray spaces are at target. + +--- + +### P1: Security and privacy fixes + +**Objective:** close data leaks and abuse paths. Small, surgical, test each one. + +**Files to read:** `Hosts/Musify.Api/Endpoints/UserEndpoints.cs`, `PlayListEndpoints.cs`, +`Application/PlayLists/*.cs`, `Application/Services/UploadIntentValidator.cs`, +`Application/Tracks/CreateTrack.cs`, `Application/Albums/CreateAlbum.cs`, +`Application/Albums/UpdateAlbum.cs`, `Application/PlayLists/CreatePlayList.cs`, +`Application/PlayLists/UpdatePlayList.cs`, the upload validators in `Hosts/Musify.Api/Validators`, +`Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs`. + +**Tasks** +1. **Delete `POST /users`** (anonymous, lets anyone create a user with any id). Remove + `CreateUserCommand`, `CreateUserRequest`, `CreateUserRequestValidator` and their tests. Users + are provisioned only by `SyncUserCommand` on token validation. The web-player doesn't call it. +2. **Private playlists:** + - `GET /playlists` (all users' playlists, not used by the web-player): delete it and + `GetPlayListsQuery`. + - `GET /playlists/users/{userId}`: remove the `onlyPublic` parameter; the handler gets + `ViewerId` and applies `VisibleTo(viewerId)` (owner sees everything, others only public). + Contract change: web-player `user/[id]/+page.server.ts` stops sending `onlyPublic`. + - `GET /playlists/{id}/tracks`: take `CurrentUser`, return `NotFound` when the playlist isn't + visible to the viewer (same rule as `GetPlayListById`). + - `GET /playlists/{id}/cover`: **decision (recommended default): leave public.** The web-player + proxies covers without the user's token (`web-player/src/lib/server/image.ts`) and ids are + unguessable GUIDs. Write that decision in the Log. If you choose to protect it, the proxy + must forward the session token and the cache header must become `private`. +3. **Upload intents:** `ValidateAndLoadAsync` takes the expected `UploadIntentPurpose` and returns + `Validation` if it doesn't match. `CreateTrack` rejects `PictureIntentId == AudioIntentId`. +4. **Upload request validation** (both picture validators and the track one): + - `FileType`: letters/digits only, 1–10 characters, from an allow-list (`jpg, jpeg, png, webp` for + pictures; `mp3, wav, flac, ogg, m4a, aac` for audio). Put the lists in one static class in + `Hosts/Musify.Api/Validators/Uploads.cs` so the three validators share them. + - `ContentType`: must match the allow-list (`image/*` subset, `audio/*` subset). + - `ExpectedSizeBytes`: `> 0` and `<= UploadIntentConfiguration.MaxUploadBytes`. +5. **Gateway range clamp:** when a ticket has `maxBytes`, an unparsable `Range` header or a + multi-range request must be **replaced** by `bytes=0-{maxBytes-1}` (not passed through). + Handle suffix ranges (`bytes=-N`) by rejecting them with 416 when `maxBytes` is set. +6. **Listening data privacy — decision (recommended default): require auth and self only** for + `GET /users/{id}/listening-stats` (the web-player only reads it for the current user; check + before changing). Leave `listening-history` and `last-listened-track` public (the profile page + shows them) and write that in the Log. + +**Tests to add:** anonymous `POST /users` → 404/405; other user's private playlist via +`/playlists/users/{id}` and `/playlists/{id}/tracks` → hidden; intent purpose mismatch; +same intent twice in `CreateTrack`; gateway middleware with multi-range and invalid range when +`maxBytes` is set. + +**Closing criteria:** tests above green; web-player regenerated and `npm run check` clean. + +--- + +### P2: Processing pipeline bugs (Worker side) + +**Files to read:** `Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs`, +`RoutingSlip/Builders/*.cs`, `Sagas/*.cs`, `Activities/Audio/*.cs`, both `appsettings.json` +(Api, Worker), `Application/Configuration/UploadIntentConfiguration.cs`. + +**Tasks** +1. `ProcessingSlipFaultConsumer`: add the `AlbumPicture` case publishing + `AlbumPictureProcessingFailed`. Today album sagas stay in `Processing` forever on failure. +2. Config keys: the appsettings use `TempRootPrefix`, `TempUploadsRetentionDays`, + `TempCleanupJobIntervalSeconds`; the class expects `TemporalRootPrefix`, + `TemporalUploadsRetentionDays`, `TemporalCleanUpJobIntervalSeconds`, so they're silently + ignored. Rename the **class** properties to `TempRootPrefix`, `TempUploadsRetentionDays`, + `TempCleanupJobIntervalSeconds` (shorter, and what ops already configured), keep the value + `"temp"`. Check that no object with the `temporal/` prefix is left in the bucket (the clean-up + job only lists the configured prefix); if there are, note it in the Log for a one-off clean-up. +3. The create-track, create-album and create-playlist slips (copy to final + consume intents + + publish processing event) have no fault subscription: if they fault the entity stays + `Pending` forever. Add `TrackFaults(...)` with new process kinds `TrackCreation`, + `AlbumCreation`, `PlayListCreation` that publish the entity's `*ProcessingFailed` event + directly (so the existing failed-consumers mark it as failed and remove files). +4. Duration precision: `TranscodeAudioActivity` stores `(int)Math.Round(...)`; store the `double` + `TotalSeconds`. +5. `UpdateTrackAudioActivity`: when the folder key is empty it publishes `TrackAudioProcessed` + without marking the audio as completed. Throw instead (it's a programming error) so the slip + faults and the saga marks the track failed. +6. Sagas and picture **updates**: `*PictureProcessed` / `*PictureProcessingFailed` for an update + arrive at a saga that was finalized after creation. Configure + `OnMissingInstance(m => m.Discard())` on those events in the three state machines, and verify + against a local RabbitMQ that updating a picture no longer puts messages in `*_error` queues. + Record the before/after in the Log (this was "to verify" in the audit). + +**Closing criteria:** manual run of the stack (`deploy/compose.dev.yml`): create album with a +corrupt image → album ends `Failed`; update a playlist picture → no `_error` messages. + +--- + +### P3: Query and API bugs + +**Files to read:** `Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs`, all +`Application/**/Get*.cs`, `Application/PlayLists/UpdatePlayList.cs`, `CreatePlayList.cs`, +`Application/Albums/CreateAlbum.cs`, `UpdateAlbum.cs`, `DeleteAlbum.cs`, `Application/Likes/*.cs`, +`Application/Users/FollowUser.cs`, `Application/*/AddTrackTo*.cs`, +`Infrastructure/Persistence/Database.cs`. + +**Tasks** +1. **403 for "not owner":** replace the 10 `Error.Unauthorized()` with `AppErrors.Forbidden` + (B.3.6). In `ErrorOrHttpExtensions`, map `Forbidden` to `Results.Problem(statusCode: 403)`, + and make `NotFound`/`Conflict` return ProblemDetails too (today they return a bare string). + Update `.Produces(401)` to `.Produces(403)` on owner-only endpoints. Contract change. +2. **Pagination bounds:** introduce `PageRequest` + validator (B.3.4) on every paginated endpoint + (today `pageSize=0` → 500, `pageSize=1000000` → unbounded). Bound `limit` on + `/albums/recent` to `1..50`. Remove the ad-hoc clamp in `GetTracksByUserId`. +3. `GetPlayListsByUserId`: `totalCount` is computed before the `Active` filter; move the filter + before counting (P7 will replace this with `ToPaginatedAsync` anyway). +4. **Lifecycle filter:** public reads (`GetTracks`, `GetTracksByUserId`, `GetTrackById`, + `GetAlbums*`, `GetAlbumById`, `GetAlbumTracks`, `GetPlayListTracks`, `GetLikedTracks`, + `GetGenres`, `MixItemMapper`, `GetListeningHistory`, `GetRecentlyListenedAlbums`) only return + `Active` rows. Stream only `Active` tracks. +5. **Covers before processing:** `CreateAlbum`/`UpdateAlbum`/`CreatePlayList`/`UpdatePlayList` + set `Small/Medium/LargeName = objectName`, so `/cover?size=small` looks for + `…/SmallPictures/{objectName}`, which doesn't exist until processing ends → S3 throws → 500. + Fix: resized names stay `null` until the worker sets them (`EntityPictures.Pending` in B.3.3 + sets only `OriginalName`), and the cover query falls back to the **original** key while the + resized one is missing. Also make the cover endpoints return 404 (not 500) when the object + doesn't exist (`HeadObjectAsync` or catch `NoSuchKey` in the storage service → `null`). +6. **Races → 500:** use `TrySaveChangesAsync` (B.3.7) in `ToggleTrackLike`, `FollowUser`, + `AddTrackToPlayList`, `AddTrackToAlbum`, mapping the unique violation to a no-op success + (like/follow) or `Conflict` (add track). Add a unique index on + `PlayListHasTrack (PlayListId, TrackId)` (migration; dedupe existing rows in the migration + with `DELETE … USING` keeping the lowest position). +7. **Track/position numbering:** compute next position with + `MaxAsync(x => (int?)x.Position) ?? -1` in one query. Decide one base for both playlist + positions and album track numbers (recommended: playlists 0-based internal position, album + track numbers 1-based because they're user-visible; document it in the Log) and renumber on + removal in **both** (playlists currently leave gaps). +8. `UpdatePlayList`: trim the name; allow clearing the description (`""` → `null`; `null` means + "unchanged"); validate `NewVisibility` with `IsInEnum()`. `CreatePlayList`: trim the name. + Same `IsInEnum()` on `CreatePlayListRequest.Visibility`. +9. `GetMixesByUserId` N+1: one query for all cover items of all mixes. +10. `GetLastTrackListenedByUserId` counts uncounted listens while the history only shows counted + ones: filter `IsCounted` for consistency, and return 204 (not 404) when there's none (check + the web-player call site in `(app)/+page.server.ts` or wherever it's read). +11. `GetListeningStats` streak: count the streak ending today **or yesterday** (a streak isn't + broken until the day is over). +12. `DeleteAlbum` removes the row directly and leaves its pictures in storage. Give it the same + shape as playlists: mark `Removing`, publish `DeleteAlbumEvent`, and a + `DeleteAlbumRoutingSlipBuilder` that removes the four pictures and the row. (If P11 already + landed, build it with the generic activities.) + +**Closing criteria:** each task has a test (Application tests for handlers, Api tests for status +codes); web-player regenerated; `npm run check` clean. + +--- + +### P4: Dead code removal + +**Objective:** delete what nothing uses, before refactoring it. + +**Tasks** +1. `Infrastructure/Services/SingleFlightCache.cs` + its test + `Microsoft.Extensions.Caching.Memory` + from `Infrastructure.csproj` (the API gets `IMemoryCache` from `AddMemoryCache`). +2. `IStorageService.GetUrlAsync` and `RemoveFolderAsync` (only tests use the latter) + tests. +3. `IAudioTranscoderService.IsValidAudioFileAsync` + implementation + `BuildValidateAudioArguments`. +4. `DownloadFileFromUrlActivity`, `DownloadFileFromUrlArguments`, `DownloadFileFromUrlLog`, its + registration, `ActivityNames.DownloadThumbnail`, and `services.AddHttpClient()` in the Worker. +5. `TrackAudio.DownloadRequested`, `RetryCount`, `LastRetryAt` and their mapping (migration + `DropTrackAudioRetryColumns`). +6. `Upload` entity, `UploadState` enum, `IDatabase.Uploads`, the `Upload` writes in + `StorageService` (migration `DropUploadTable`). Update `docs/architecture.md` (mentions + `Upload`). +7. `DatabaseDesignTimeFactory`: remove the unused `optionsBuilder`. +8. `Database.cs`: remove the `AppIDatabase` alias. +9. Unused locals (`var response =` in `StorageService`), `.AsNoTracking().AsQueryable()`, + redundant `database.X.Update(entity)` on tracked entities, the always-true + `ExpiredIntentsRetentionDays >= 0`, the duplicated `if (pictureIntent != null && …)` in + `UpdateAlbum`/`UpdatePlayList`, `AddPictureService`'s unused `configuration` parameter, + `AddUploadIntentConfiguration` (already done by `AddApplication`; see P13 for the Worker). +10. `Newtonsoft.Json` from `Api.csproj` and `Worker.csproj`; package references in `Api.csproj` + and `Worker.csproj` that come transitively from Infrastructure (AWSSDK.S3, MassTransit.*, + Npgsql, EF, OpenTelemetry.*, MimeMapping, ImageSharp, X.PagedList.EF). Keep only what the host + uses directly. Rename the Worker `UserSecretsId` prefix (`dotnet-PictureWorker-…`) only if + nobody relies on it locally (ask; otherwise leave and note). +11. `appsettings.json` of the API: remove `AudioTranscoder` and `Workers` (Worker-only). + Worker: remove `AudioTranscoder.Routes.WorkingDirectory` (doesn't exist). +12. `CurrentUser.HasClaim` / `FindClaim` / `Principal` if unused after grep. + +**Closing criteria:** grep proves each symbol is gone; migrations added; tests green. + +--- + +### P5: Configuration unification + +**Prerequisites:** P2 (config key rename there). + +**Tasks** +1. Create `Pictures.cs` (B.3.1) and `ImageSizes` (B.3.2); rewrite `AlbumConfiguration`, + `PlayListConfiguration`, `TrackConfiguration` on top of them. Update every call site + (`BuildSmallPicturePath(x)` → `BuildPicturePath(PictureSize.Small, x)`). +2. Update all `appsettings*.json` with the renamed keys and grep `deploy/`. +3. **One way to register options:** `AddValidatedOptions` (move it to + `Application/Configuration/ConfigurationDependencyInjection.cs` if not there, public). Replace + `RegisterValidatedOptions` in MassTransit DI and the inline copies in `AddDatabase`, + `AddStorageService`, `AddObservability`, `AddAudioTranscoder`, `AddStreamTicketService`, + `AddAuthenticationConfiguration`. Add `public interface IConfigurationOptions { static abstract string SectionName { get; } }`, + implement it on every config class, and add the overload + `AddValidatedOptions(this IServiceCollection services, IConfiguration configuration) where T : class, IConfigurationOptions` + so call sites stop repeating the section name. +4. Parameter name `services` everywhere (not `serviceDescriptors`). +5. Gateway: rename `StreamTicketValidationOptions` → `StreamTicketValidationConfiguration` and + inject the POCO like the rest (no `IOptions` in constructors). + +**Closing criteria:** `grep -r "AddOptionsWithValidateOnStart" backend` finds only +`AddValidatedOptions`; `new ImageSize(` appears only in `PictureSizes.ToImageSizes`. + +--- + +### P6: Domain cleanup + +**Tasks** +1. `EntityPictures` (B.3.3) replaces `AlbumPictures` and `PlayListPictures`. +2. `IHasLifeCycle` (B.3.8) and `IOwnedEntity` (B.3.6); rename `PlayList.UserId` → + `OwnerUserId` with `[Column("UserId")]`. +3. Apply B.2 rule 10 to every entity: drop redundant `[Required]`, redundant initializers, + `#pragma warning disable CS8618` (use `= null!` on navigations), `required` on `Track.Owner` + (then `CreateTrack` no longer needs to load the `User` tracked; it sets `OwnerUserId` only). +4. Rename `PlaylistVisibility` → `PlayListVisibility` (enum type name only; the DB stores ints, + JSON stores names, so no migration and no contract change — verify the OpenAPI schema name + change doesn't break web-player types; regenerate). +5. Table names: **decision (recommended default): don't rename tables** (pure churn + risky + migration). Instead make every entity declare `[Table("…")]` explicitly with its current name + so the inconsistency is visible and frozen. Note in the Log. +6. `UploadIntent`: move `IsExpired`'s clock read out of the entity (`IsExpiredAt(DateTime now)`) + so it's testable. + +**Closing criteria:** `dotnet ef migrations add Check` produces an empty migration (delete it); +tests green. + +--- + +### P7: Application shared helpers + +**Prerequisites:** P6. + +**Tasks** +1. Create `Pagination.cs` (B.3.4), `*Projections.cs` + `TextNormalizer` (B.3.5), `AppErrors` + + `OwnershipExtensions` (B.3.6), `TrySaveChangesAsync` (B.3.7) if P3 didn't. +2. Rewrite every list query with `…Active().VisibleTo(…).OrderBy(…).SelectResponse().ToPaginatedAsync(page, ct)`. + Delete every `StaticPagedList`, `ToPagedListAsync`, `Include` used only for mapping, and the + `X.PagedList*` package references. +3. Delete `TracksSearchResponse`, `TrackSearchItemResponse`, `AlbumsSearchResponse`, + `AlbumSearchItemResponse`; `GetTracks` and `GetAlbums` return `PaginatedResponse<…>`. + **Contract change:** web-player `explore/+page.svelte` uses `items.map((item) => item.track)` + and the album equivalent; update to use items directly. +4. Replace the load/exists/owner/log boilerplate in every command with `FindOwnedAsync`. +5. Apply B.2 rules 2–5 and 7 in every handler: `AppErrors`, `Result.Success`, no try/catch around + save/publish, logging policy, plain return types for handlers that can't fail (`GetUsers`, + `GetLikedTracks`, `GetRecentlyListenedAlbums`, `GetMixesByUserId`…), `GetListeningHistory` + returns `IReadOnlyList`. +6. Move inline responses to `Responses/`: `UserProfileResponse`, `ListeningStatsResponse`, + `GenreResponse`, `AvailableGenreResponse`, the three `*CoverLocation` (which become one + `CoverLocation` in P8). +7. Use `TextNormalizer.Normalize` for every normalized column and search term. +8. Deterministic ordering with `Id` tiebreaker everywhere; `GetTracksByUserId` orders by + `CreatedAt` descending like the rest. +9. `PlayListProjections.SelectResponse` adds `OwnerUserId` (string-serialized, A.3.3), + `TrackCount` and `DurationSeconds` (sum of active tracks) to `PlayListApplicationResponse` + (additive contract change, needed by web-player P3.1 and P8.1; see A.4). + +**Closing criteria:** A.5 metrics for cover-ids projection, listens-count projection and +`StaticPagedList` at target; line count of `Application` down ≥ 20 %. + +--- + +### P8: Pictures feature deduplication + +**Prerequisites:** P5, P6, P7. + +**Objective:** Album and PlayList picture flows are copies of each other (and of the picture half +of Track). Make them one implementation. + +**Tasks** +1. `Application/Pictures/RequestPictureUpload.cs`: one internal service + `PictureUploadIssuer.IssueAsync(userId, purpose, IPictureOwnerConfiguration, fileType, contentType, expectedSize, ct)` + returning `ErrorOr`; `RequestAlbumPictureUpload` and + `RequestPlayListPictureUpload` become thin commands calling it. `AlbumPictureUploadResponse` + and `PlayListPictureUploadResponse` merge into `PictureUploadResponse` (same shape; OpenAPI name + changes → regenerate web-player types). `RequestTrackUploadUrls` reuses the intent-creation + part for both of its intents. + Also: issue the pre-signed URL **after** the quota check (today it's issued first and wasted + on rejection). +2. `Application/Pictures/GetCover.cs`: one `GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size)` + (`enum CoverOwner { Track, Album, PlayList }`), one `CoverLocation`, content type from + `MimeUtility` for all three (Track currently hand-rolls it). Size is parsed in the API with + `Enum.TryParse` (default `Medium`). +3. Create/Update album and playlist: extract `PictureSourceChange.PrepareAsync(intentId, userId, IPictureOwnerConfiguration, purpose)` + returning the `EntityPictures` and the event payload; handlers shrink to entity changes + + publish. +4. Events: `CreateAlbumResourcesEvent`/`CreatePlayListResourcesEvent` and + `UpdateAlbumPictureSourceEvent`/`UpdatePlayListPictureSourceEvent` keep separate types (the + sagas correlate on them) but carry `ImageSizes Sizes` (B.3.2). Message contract change (A.2). +5. `UploadIntentValidator`: stop receiving `UploadIntentConfiguration` as a method parameter; + inject it. Quota: compute count and bytes in one aggregate query instead of loading rows, use + the audio default for audio intents, and ignore intents whose `ExpiresAt` already passed. + +**Closing criteria:** `diff` between album and playlist handlers is only names; the picture +handlers in `Albums/` and `PlayLists/` are each ≤ 40 lines. + +--- + +### P9: Responsibilities + +**Tasks** +1. **Queries don't write:** split `GetTrackStreamQuery` into `StartListeningCommand` (creates the + `ListeningHistory`, returns stream ticket + `listenId`). `TrackStreamIssuer` only issues the + ticket and builds the URL. Same HTTP route and response (no contract change). +2. **Serialization belongs to the API:** move `LongAsStringConverter`/`NullableLongAsStringConverter` + to `Hosts/Musify.Api/Serialization/`, remove `[JsonConverter]` attributes from Application + responses, and register both converters globally in `ConfigureHttpJsonOptions`. That is safe + because the only `long` values the API returns are user ids (`ExpectedSizeBytes` is + request-only and the converter also reads numbers; `TotalItemCount` is `int`); re-check with + a grep before merging. Update the OpenAPI schema transformer to mark every `long` as string, + and check that the regenerated web-player types don't change (they already see strings). +3. **One validation place:** tag rules only in `CreateTrackRequestValidator` (remove from the + handler, keep a domain guard `GenreCompatibility.FindConflicts` call in the handler only if + commands can be sent from somewhere other than the API — they can't today, so remove); + `RecordListeningProgress` gets a FluentValidation validator instead of handler checks; genre + parsing in `GetTracks` moves to binding (`Genre? genre` with a validator). +4. **Endpoint orchestration + bulk insert:** the `AddPlayListTrack` endpoint calls one command + and stops re-querying the track. Replace it with `POST /playlists/{id}/tracks` taking + `{ "trackIds": [...] }` (1–500 ids) → 204, backed by `AddTracksToPlayListCommand` that appends + the tracks in order in one transaction, skipping those already present. This is the bulk + endpoint web-player P8.4 asks for (A.4) and removes the per-track POST loop in + `web-player/src/lib/server/playlistActions.ts`. For consistency, albums get the same shape + (`POST /albums/{id}/tracks` with `trackIds`), replacing `POST /albums/{albumId}/tracks/{trackId}`. + Contract change on both; update the two web-player call sites. +5. **`UserHasTrack`:** it duplicates `Track.OwnerUserId` (written only on create). Replace its reads + (`GetTracks`, `GetTracksByUserId`, `GetGenres`, `GenerateMixesForUser`) with `Tracks` filtered + by owner/active, then drop the table (migration). If the intent was a future "library" (saved + tracks), write that in the Log and keep it, but then it must not be written on create. +6. **Mix texts:** move the Spanish titles out of `GenerateMixesForUser` (B.2 rule 13): store a + `MixKind` enum (`Discovery`, `Daily`) instead of title/subtitle strings and let the web-player + render the text. Migration + contract change. **Decision (recommended default): do it**; if + not, at least move the strings to a single constants class. +7. `GenerateMixesForUser`: rely on cascade delete for `MixItems` (remove the manual item query). + +--- + +### P10: Infrastructure services + +**Tasks** +1. `StorageService`: after P4 it no longer depends on `IDatabase`; register it as singleton. + Use `StorageKey.Combine` for keys; fix the `trasnsferUtility` typo and dispose it; fix + `Path.Combine(sourceDirectory, file)` (file is already a full path); prefix normalisation and + pagination helpers shared by `ListObjectsAsync`; no log-and-rethrow (B.2 rule 5); consistent + log levels (`Debug` for per-object operations). `GetFileAsync` returns `null` on `NoSuchKey` + (used by the cover endpoints, P3.5). `IStorageService`: `CancellationToken` last + (`preventOverwrite` before it), parameter names camelCase. +2. `AudioTranscoderService.cs`: rename the class to `AudioTranscoderService` (file already has + that name). Access modifiers, no log-and-rethrow, one `ExecuteFfmpegAsync(arguments, workingDirectory, Stream? input, …)` + instead of two near-identical methods. +3. `PictureService`: no log-and-rethrow. +4. `MassTransitEventBus`: expression-bodied, no `nameof`. +5. **Jobs:** one mechanism. Recommended: **Hangfire for all four** (it already runs in the Worker, + gives retries and a dashboard). Convert `UploadIntentExpirationJob` and + `TemporalUploadsCleanUpJob` (rename file/class to `TempUploadsCleanupJob`) to Hangfire recurring + jobs with the configured intervals, and move their logic behind commands in Application + (`ExpireUploadIntentsCommand`, `DeleteStaleTempUploadsCommand`) like + `DeleteStaleUncountedListensCommand`. Rename `AddDailyMixGenerationJob` to `AddJobs` and + register all jobs there. Job recurring registration moves out of `Worker/Program.cs` into an + extension in Infrastructure. +6. Observability: `AddObservability` also used by the Worker (P13). +7. `JwtBearerEventsHandler` (Api): dispose `HttpRequestMessage`/response; read the userinfo + endpoint from the OIDC discovery document instead of hardcoding `/oidc/v1/userinfo`. + +--- + +### P11: MassTransit deduplication + +**Prerequisites:** P5 (`ImageSizes`), P6 (`IHasLifeCycle`). + +**Tasks** +1. `RoutingSlips` helper (B.3.9); rewrite every builder and failed-consumer with it. Delete + `EndpointHelper`. +2. Merge `AlbumPictureSourceRoutingSlipBuilder` and `PlayListPictureSourceRoutingSlipBuilder` + into `PictureSourceRoutingSlipBuilder` (the only difference is the publish-activity + arguments; pass a factory or a `CoverOwner`). +3. Generic lifecycle activities: `MarkLifeCycleActivity` (arguments: `Guid Id`, + `LifeCycleStatus Status`) and `DeleteEntityActivity` for `Track`, `PlayList`, `Album`. + Replaces `Mark{Track,PlayList,Album}As{Failed,Removing}Activity` and + `Delete{Track,PlayList}FromDbActivity` (+ their arguments). Keep distinct endpoint names per + entity (`mark-track-lifecycle`, …) so queues stay separate. Message contract change (A.2). +4. `Update{Track,PlayList,Album}PictureActivity`: one generic base + `UpdatePicturesActivity` with an abstract "apply/restore pictures" hook; Track + additionally sets `ProcessingStatus`. Compensation for Album/PlayList should also leave a + consistent state (today only Track marks failed). +5. `*ProcessingFailedConsumer`: one shared method building the clean-up slip from + `(entity, bucket, keys[])`. +6. The 9 one-line consumers (`Create*Consumer`, `Update*Consumer`, `Delete*Consumer`) stay one + class per message (MassTransit needs it) but become expression-bodied with no blank lines. +7. The three Audio activities repeat "load track or throw, set status failed on error": extract + `TrackAudioStatus.MarkFailedAsync(database, trackId, ct)`. +8. `GeneratePictureWorkflowPathsActivity` isn't async: return `Task.FromResult`. +9. DI: outbox configuration in one local function (used by client and consumers); saga repository + configuration in one local function (used 3×); keep registration lists alphabetised. +10. `ThrowIfNull` without `nameof`; injected services named after their type (`storageService`, + `pictureService`). + +**Closing criteria:** `Infrastructure/MassTransit` line count down ≥ 30 %; manual end-to-end run: +create/update/delete track, album and playlist, plus a forced failure for each. + +--- + +### P12: API layer + +**Tasks** +1. `Program.cs`: `WebApplication.CreateBuilder(args)`; sorted usings; CORS constant at top. +2. Cover endpoints: one `CoverEndpoint.Stream(...)` helper used by the three `/cover` routes. +3. Routes: parameters named `{id}` for the resource itself and `{trackId}` for sub-resources; + Guid routes use `:guid`, user ids `:long`. `PlayList` routes keep the `/playlists` path. +4. Every endpoint returns `IResult` through `ToHttpResult` (no raw return types), including the + ones whose handlers can't fail (use `Results.Ok`). +5. Update DTOs: drop the `New…` prefix (`UpdateAlbumRequest(Title, Description, ReleaseYear, PictureIntentId)`) + — contract change, regenerate web-player; or keep and write the reason in the Log. + **Decision (recommended default): rename**, it's the only place with that prefix. +6. Validators: one shared upload validator (P1.4 created the allow-lists); album and playlist + picture validators become one generic `PictureUploadRequestValidator`, or merge the DTOs + into `PictureUploadRequest` (preferred, since the responses merged in P8). +7. `OpenApiOptionsSetup` and `ScalarOptionsSetup`: share the `SecuritySchemeId` constant. +8. `.WithSummary(...)` strings: sentence case, no trailing period, consistently. +9. `JwtBearerOptionsSetup`: single-line guard without braces (B.1.1 `when_multiline`). +10. Move the validation limits into one `Hosts/Musify.Api/Validators/Limits.cs` (playlist name + 50, descriptions 256, titles 200, user name 48, release year range) used by every validator, + and list them in the Log so web-player P7.1 copies the same values (A.4). If the two plans + disagree on a limit (e.g. the web-player draft has playlist description 300 and track title + 100), the database column length wins. + +--- + +### P13: Worker and Gateway hosts + +**Tasks** +1. Worker `Program.cs`: call `AddApplication(configuration)` instead of registering Application + options by hand (it currently forgets `AlbumConfiguration`); add `AddObservability`; remove + `ValidateOnBuild = false` and make the container validate. If a handler has dependencies the + Worker can't provide (stream tickets, gateway config), register those configs too — they're + just POCOs — rather than disabling validation. +2. Worker `appsettings.json`: complete and consistent with the API for shared sections (after P5 + the keys changed), remove stale ones. +3. Gateway: `Program.cs` uses `AddValidatedOptions`-style registration (it doesn't reference + Application; copy the 6-line helper locally, or reference nothing and keep + `AddOptions…ValidateOnStart` — **decision (recommended default): keep it local and + self-contained**, the gateway must stay dependency-free); field naming per B.1.1 (no + underscores; rename the middleware field that shadows the `options` constructor parameter); + `appsettings.json` default bucket in the YARP transform is overridden by compose — replace it + with an obviously-placeholder value (`"/buckets/CHANGE_ME/{**rest}"`) so the override is + explicit, or document it in `docs/streaming.md`. + +--- + +### P14: Tests + +**Tasks** +1. Add tests for what has none today: MassTransit activities (at least the generic lifecycle and + picture-update activities with the SQLite `TestDatabase`), `ProcessingSlipFaultConsumer` + (every process kind), routing slip builders (activity order and arguments), saga state + machines with MassTransit's test harness (happy path, failure, missing instance on update). +2. Endpoint tests for visibility/ownership (P1, P3) if not already added there. +3. Keep `TestDatabase` in sync with `Database.OnModelCreating`: extract the owned-type and index + configuration of `Database` into `ModelConfiguration.Apply(ModelBuilder)` in Infrastructure + and call it from both, so the test model can't drift. (Requires `Application.Tests` to + reference Infrastructure, or move the test DB to `Infrastructure.Tests`; pick the former and + note it.) + +--- + +### P15: Final sweep and documentation + +**Tasks** +1. Recompute the A.5 metrics (script below) and fill D.1. +2. Grep for leftovers: `Error.Unauthorized(`, `StaticPagedList`, `new ImageSize(`, + `ThrowIfNull(.*nameof`, `#pragma warning disable CS8618`, `try` blocks around + `SaveChangesAsync` in `Application/`, `serviceDescriptors`, `storageHandler`, `PlaylistVisibility`. +3. Update `docs/architecture.md`, `docs/media-processing.md`, `docs/projects.md`, + `docs/storage.md`, `docs/streaming.md` with the new names (jobs, entities, routes). +4. Add a short `backend/CLAUDE.md` with section B.2 (conventions) so future work follows them. +5. Keep this document as the record: don't delete section E, just make sure every row points to + a checked part in D. + +Metrics script (from `backend/`, Git Bash): + +```bash +F=$(git ls-files | grep "\.cs$" | grep -v Migrations | grep -v Tests); cat $F | wc -l; grep -c "new StaticPagedList" $F | awk -F: '{s+=$2} END {print s}'; grep -c "new ImageSize(" $F | awk -F: '{s+=$2} END {print s}' +``` + +--- + +## D. Log + +| Part | Done | Date | Notes (decisions, contract changes, migrations, queue drains) | +|---|---|---|---| +| P0 Formatting and guardrails | [x] | 2026-09-24 | `.editorconfig` + analyzers added. Rules downgraded to `suggestion` (need behaviour/API changes, revisit in P15): CA1848, CA1873, CA1305, CA1707, CA1859, CA1716, CA1000, CA1725, CA1816, CA1822, CA1852, CA1068, CA1711, CA1861. 350 tests green (Docker available). | +| P1 Security and privacy | [x] | 2026-09-24 | Deleted POST /users, GET /playlists; /playlists/users/{id} lost onlyPublic (owner sees all, others public); /playlists/{id}/tracks hides non-visible playlists; intent purpose checked, same intent for picture+audio rejected; upload validators use allow-lists in Validators/Uploads.cs; gateway replaces multi/invalid Range and rejects suffix ranges with 416 when maxBytes is set; listening-stats now requires auth and self (403 otherwise). Decisions: /playlists/{id}/cover stays public; listening-history and last-listened-track stay public. Contract changes: web-player schema regenerated, onlyPublic call removed. Test helper ApiTestFixture.SeedUserAsync replaces POST /users. | +| P2 Pipeline bugs | [x] | 2026-09-24 | Album picture failures now reported; TempRootPrefix/TempUploadsRetentionDays/TempCleanupJobIntervalSeconds class properties renamed to match appsettings (default value temp); create track/album/playlist slips have fault subscriptions (new process kinds TrackCreation/AlbumCreation/PlayListCreation, slips carry Bucket/PictureKey/AudioKey variables so cleanup works); update-source slips fault as TrackPicture-style (AlbumPicture/PlayListPicture kinds); duration stored as double; empty audio folder key now throws; sagas discard picture/audio events for missing instances. NOT verified: manual stack run with RabbitMQ (no local stack); objects under the old temporal/ prefix in the bucket, if any, need a one-off clean-up. Queues should be drained before deploy (new variable names/process kinds). | +| P3 Query and API bugs | [x] | 2026-09-24 | All 12 tasks done. Contract changes: 403 (ProblemDetails) instead of 401 for not-owner; NotFound/Conflict now ProblemDetails; page params validated (pageNumber>=1, pageSize 1..100, bound through Api PageQuery, default size 20) and /albums/recent limit 1..50 -> 400; /users/{id}/last-listened-track returns 204 when empty; DELETE /albums/{id} is now async (Removing + DeleteAlbumEvent + DeleteAlbumRoutingSlipBuilder). Web-player: schema regenerated, fetchAllPages helper replaces pageSize 200/500 calls. Numbering decision: playlist positions 0-based (renumbered on removal), album track numbers 1-based (already renumbered). Migration PlayListTrackUnique (dedupes rows, unique index). Covers: resized names stay null until processed (AlbumPictures/PlayListPictures.Pending, cover falls back to original, S3 NoSuchKey -> GetFileAsync returns null -> 404). PageRequest helper + AppErrors + TrySaveChangesAsync created here (rest of P7 helpers still pending). Queues: new DeleteAlbumEvent, drain not required. | +| P4 Dead code | [x] | 2026-09-24 | All 12 tasks. Merged the two migrations into one: DropUploadTableAndTrackAudioRetryColumns. Removed SingleFlightCache (+test, Caching.Memory), IStorageService.GetUrlAsync/RemoveFolderAsync, IAudioTranscoderService.IsValidAudioFileAsync, DownloadFileFromUrl activity/args/log, Upload entity/UploadState/IDatabase.Uploads and StorageService DB writes (StorageService no longer depends on IDatabase), CurrentUser.HasClaim/FindClaim/Principal. Newtonsoft.Json pin moved to Infrastructure.csproj (Hangfire pulls a vulnerable 11.0.1 transitively; removing it from Api/Worker triggered NU1903). Api/Worker lost their redundant package references. AppIDatabase alias removed (Database now names Musify.Application.Contracts.IDatabase in full because it clashes with EF Storage.IDatabase). Kept AddUploadIntentConfiguration (Worker needs it until P13). Worker UserSecretsId left as is. Removed AudioTranscoder/Workers from Api appsettings, Worker AudioTranscoder.Routes.WorkingDirectory, and the matching env vars in deploy/compose.yml. docs/architecture.md and development.md updated. | +| P5 Configuration | [x] | 2026-09-24 | Pictures.cs (PictureSize, PictureRoutes, PictureSizes, IPictureOwnerConfiguration, PictureSizeParser), ImageSizes, IConfigurationOptions (static abstract SectionName; every config POCO implements it) and AddValidatedOptions(services, configuration) added; all inline/RegisterValidatedOptions copies removed. Config key renames: ParentFolders->ParentFolder, ProcessedAudioFolder->ProcessedAudiosFolder, Picture -> (Api and Worker appsettings updated, nothing in deploy/ used them). BuildTempAudioPath/BuildTempPicturePath -> BuildTempPath; BuildSmall/Medium/LargePicturePath -> BuildPicturePath(PictureSize, name); the un-prefixed BuildOriginalPicturePath(name) overload is gone. Events still carry Small/Medium/Large ImageSize (ImageSizes in events comes in P8.4). serviceDescriptors renamed to services. Gateway: StreamTicketValidationOptions -> StreamTicketValidationConfiguration, injected as POCO. TrackRoutes keeps PresetXPicturePath props (still used). | +| P6 Domain | [x] | 2026-09-24 | EntityPictures replaces AlbumPictures/PlayListPictures (Pending factory); IHasLifeCycle and IOwnedEntity live in Domain/Abstractions (Domain cannot reference Application); PlayList.UserId -> OwnerUserId ([Column(UserId)]) and User nav -> Owner; entity noise removed (redundant Required/initializers/pragma, nav = null!, Track.Owner no longer required; CreateTrack still loads the user because the response needs the artist name); PlaylistVisibility -> PlayListVisibility (OpenAPI schema name changed, web-player regenerated, check clean); explicit Table attributes with current names (tables NOT renamed); UploadIntent.IsExpired -> IsExpiredAt(now). Verified with an empty migration (deleted); only the model snapshot changed. No schema migration. | +| P7 Application helpers | [x] | 2026-09-24 | Pagination.cs (PageRequest, PaginatedResponse, ToPaginatedAsync), TextNormalizer, AppErrors, OwnershipExtensions.FindOwnedAsync (treats non-active as NotFound; IOwnedEntity has no Id, IHasLifeCycle supplies it), Track/Album/PlayListProjections. X.PagedList removed. GetTracks/GetAlbums return PaginatedResponse directly (contract: no more track/album wrapper; web-player updated). List queries keep int PageNumber/PageSize in the query record and build PageRequest inside the handler. PlayListApplicationResponse gained ownerUserId, trackCount, durationSeconds (web-player P3.1/P8.1 unblocked). Plain return types for GetUsers, GetLikedTracks, GetAlbums, GetTracks, GetPlayListsByUserId, GetRecentlyListenedAlbums, GetMixesByUserId, GetListeningHistory (IReadOnlyList). Inline responses moved to Responses/ (cover locations merge in P8). Create/Update album+playlist, Request*Upload, CreateTrack and RequestTrackUploadUrls keep their try/catch until they are rewritten in P8/P9. | +| P8 Pictures feature | [x] | 2026-09-24 | UploadIntentIssuer (IssueAsync/IssueOneAsync/IssuePictureAsync: user check, quota in a serializable transaction, presigned URLs issued AFTER the quota check) shared by album/playlist/track upload commands; PictureUploadResponse replaces AlbumPictureUploadResponse/PlayListPictureUploadResponse (OpenAPI schema renamed, web-player regenerated). GetCoverQuery(CoverOwner, Id, PictureSize)+CoverLocation replace the three cover queries; API uses CoverEndpoint.Stream helper (done here instead of P12.2). PictureSourceChange.PrepareAsync used by create/update album+playlist; events use the intent Bucket. UploadIntentValidator now injects UploadIntentConfiguration; quota is one aggregate query, uses the audio default for audio intents and ignores intents already past ExpiresAt. Events and Publish*Arguments carry ImageSizes Sizes (message contract change: drain queues before deploying). Update/Create handlers dropped their try/catch. | +| P9 Responsibilities | [x] | 2026-09-24 | StartListeningCommand replaces GetTrackStreamQuery (creates the ListeningHistory); TrackStreamIssuer is DB-free. LongAsString converters live in Api/Serialization and are registered globally (OpenAPI transformer marks response longs as string, request longs stay integer|string; web-player schema types unchanged). Tag rules only in CreateTrackRequestValidator, RecordListeningProgressRequestValidator added, genre bound with GenreParameter (400 on unknown). POST /playlists/{id}/tracks and POST /albums/{id}/tracks take { trackIds } (1-500), skip tracks already present and answer 204 (single-track routes removed; adding a duplicate is now a silent success, no 409); web-player updated, album->playlist uses one request. UserHasTrack dropped (migration DropUserHasTrack; reads use Tracks.OwnerUserId). Mix stores MixKind instead of Spanish text (migration MixKind maps the old daily title to Daily); MixApplicationResponse now has kind, web-player renders the texts (data/mixes.ts). GenerateMixesForUser relies on cascade delete. Decision on mix texts: done. | +| P10 Infrastructure services | [x] | 2026-09-24 | StorageService is a singleton without log-and-rethrow (GetFileAsync null on NoSuchKey), IStorageService.GetUploadUrlAsync now (bucket,key,contentType,expiry,preventOverwrite,ct). AudioService -> AudioTranscoderService with a single ExecuteFfmpegAsync. PictureService and MassTransitEventBus simplified. Jobs: all four are Hangfire recurring jobs registered by RecurringJobsRegistrar (hosted service) inside AddJobs (was AddDailyMixGenerationJob); logic lives in ExpireUploadIntentsCommand / DeleteStaleTempUploadsCommand; TemporalUploadsCleanUpJob renamed TempUploadsCleanupJob. Hangfire cron has 1-minute resolution, so the configured *Seconds intervals are rounded up to whole minutes (UploadIntent ExpirationJobIntervalSeconds, TempCleanupJobIntervalSeconds). GenerateMixesForUser is now a plain ICommand. JwtBearerEventsHandler disposes request/response and reads the userinfo endpoint from the OIDC discovery document (falls back to /oidc/v1/userinfo). Observability for the Worker is wired in the Worker part. | +| P11 MassTransit | [x] | 2026-09-24 | RoutingSlips helper (Create/AddStep/TrackFaults/AddRemoveFile) replaces the slip preamble and EndpointHelper; RoutingSlipVariableNames moved to the RoutingSlip namespace. Merged builders: PictureSourceRoutingSlipBuilder (album+playlist) and DeleteRoutingSlipBuilder (track/album/playlist). Generic MarkLifeCycleActivity/DeleteEntityActivity and UpdatePicturesActivity with one thin subclass per entity (separate queues kept; IDatabase gained Set()). Album/playlist picture updates now return a compensation log. ProcessingFailedCleanup shared by the three failed consumers; TrackAudioStatus shared by the audio activities; consumers are expression-bodied; DI uses AddOutbox/ConfigureSagaRepository local helpers. MassTransit folder 3547 -> ~2530 lines (-29%). Contract change: new activity endpoints (mark-*-life-cycle, delete-*, update-*-picture args/logs) - drain queues before deploying. Not done: the remaining log-and-rethrow in the Files activities, and the manual end-to-end run (no local stack). | +| P12 API layer | [x] | 2026-09-24 | Routes: resource is `{id:guid}`, sub-resource `{trackId:guid}`, user ids `{id:long}`/`{userId:long}` (OpenApiOptionsSetup maps int64 path params to string so the web-player keeps passing string ids). Contract changes: path params renamed (`albumId`/`playlistId` -> `id`, DELETE /tracks/{id}), `New*` prefix dropped from UpdateAlbumRequest/UpdatePlayListRequest, album+playlist picture DTOs/validators merged into PictureUploadRequest(+Validator), listening-stats 403 is now ProblemDetails; malformed guid/long routes now 404. Every endpoint returns IResult. Summaries in sentence case. Program: CreateBuilder(args) and RouteHandlerOptions.ThrowOnBadRequest=false (with args the test host runs as Development, which would turn bad query params into 500s). Validators/Limits.cs (web-player P7.1 must copy): track title 200, album title 200, playlist name 50, description 256 (album and playlist), release year 1877..current year+1, user name 48 (Domain only, no API validator), max 500 tracks per add request. Web-player: schema regenerated, call sites and update bodies adapted, `npm run check` clean. 365 tests green. | +| P13 Worker and Gateway | [x] | 2026-09-24 | Worker Program uses AddApplication + AddObservability + AddStreamTicketService and the container now validates (ValidateOnBuild and ValidateScopes on); checked by running the Worker in Development, it builds and starts the MassTransit endpoints. Because every Application handler is registered, the Worker also needs `Album`, `StreamGateway`, `StreamTicket` (key never read) and `Playback` sections: Worker appsettings.json now mirrors the Api for shared sections, appsettings.Development.json got the stream settings, deploy/compose.yml gives the worker StreamGateway__PublicBaseUrl, StreamTicket__PrivateKeyPath, the keys volume and keys-init dependency. IEventBus is now registered by AddMassTransitConsumers too. AddUploadIntentConfiguration removed. Gateway: kept its own options registration, no underscore fields (TicketValidator lost its unused `options` field), CreateBuilder(args), CorsPolicy constant, YARP bucket in appsettings.json is `CHANGE_ME` (compose overrides it; appsettings.Development.json carries `webapi-storage` for local runs). 365 tests green. Docs (media-processing.md Worker sections, streaming.md bucket) to update in P15. | +| P14 Tests | [x] | 2026-09-24 | ModelConfiguration.Apply(ModelBuilder) in Infrastructure/Persistence is called by Database and by the Application.Tests TestDatabase (Application.Tests now references Infrastructure; TestDatabase therefore also creates the saga tables). New Infrastructure.Tests (Infrastructure gets InternalsVisibleTo for Infrastructure.Tests and DynamicProxyGenAssembly2 so NSubstitute can mock ExecuteContext over internal argument types): ProcessingSlipFaultConsumer for every process kind plus unknown/missing kind, saga state machines (Track/Album/PlayList happy path, failure, event for a missing instance is discarded) on the MassTransit test harness, routing slip builders (create, audio workflow, the four picture-source slips, DeleteRoutingSlipBuilder against Postgres), lifecycle activities (mark/delete, missing entity is skipped, Postgres fixture instead of SQLite because the fixture already exists there), picture-update activities run inside real routing slips on the harness (execute, missing entity/variable fault the slip, compensation restores names / marks track pictures failed), RecurringJobsRegistrar.EveryCron. Api.Tests: PlayListEndpointsTests (private/public visibility, tracks hidden, per-user list, non-owner update/delete 403, non-guid id 404, name length). Observation: EveryCron maps 60+ minute intervals to whole hours by integer division (5400 s -> every hour), left as is. 432 tests green. | +| P15 Final sweep | [x] | 2026-09-24 | Metrics recomputed (D.1). Leftover greps: `Error.Unauthorized(` only in StartListening ("sign in", intended), `new ImageSize(` only in PictureSizes, no StaticPagedList/serviceDescriptors/storageHandler/PlaylistVisibility/ThrowIfNull(nameof); the two `#pragma warning disable CS8618` in TrackAudio/TrackPictures were unnecessary and removed; no try/catch around SaveChangesAsync in Application. Log-and-rethrow removed from the Copy, Download, Transfer, Upload and Resize activities (Remove keeps its NoSuchKey catch; the bare catch blocks in the audio activities mark the track failed and are not log-and-rethrow). Analyzer overrides revisited: CA1000, CA1725 (param names), CA1816, CA1852, CA1068 (cancellation tokens moved last), CA1711 (only kept off for test collection fixtures), CA1861, CA1305 (InvariantCulture), CA1859 and CA1707 (off only under Tests/) are fixed or scoped; still `suggestion`: CA1848 and CA1873 (would need [LoggerMessage] on ~110 call sites), CA1716 (`IDatabase.Set` and the `Shared` namespace, deliberate) and CA1822 (stateless DI builders). Added a `private static readonly` PascalCase naming rule. `dotnet format` applied (imports order and whitespace only) and `--verify-no-changes` is clean. docs/ (architecture, media-processing, projects, storage, streaming, development, authentication) updated to the new names, and backend/CLAUDE.md added. Temporary ZzOpenApiDumpTests deleted (to regenerate the web-player schema again, run the API in Development and export /openapi/v1.json). Every row of section E points to a part that is now checked. 431 tests green (432 with the temporary dump test); NOT verified: a manual end-to-end run of the full stack (RabbitMQ, SeaweedFS, Zitadel) after the refactor. Deploy: drain the queues first (message contracts changed in P2, P8, P11, P3) and apply the migrations `PlayListTrackUnique`, `DropUploadTableAndTrackAudioRetryColumns`, `DropUserHasTrack`, `MixKind`; the worker now needs `StreamGateway__PublicBaseUrl` and `StreamTicket__PrivateKeyPath` (+ keys volume), already added to deploy/compose.yml. | + +### D.1 Recalculated metrics (after P15) + +| Metric | Before | After | +|---|---|---| +| Non-migration `.cs` lines (excluding tests) | 12,687 | 10,424 (-17.9 %, below the -25 to -30 % target: the plan's dedup was done, the rest is behaviour the audit added such as bulk endpoints, ownership helpers, mix kinds and Hangfire jobs). MassTransit 3,547 -> 2,482, Application 3,811 | +| `new StaticPagedList` | 7 | 0 | +| `new ImageSize(` | 15 | 3 (inside `PictureSizes`) | +| Files with block namespace | 249 | 0 (306 file-scoped; 4 files have no namespace: AssemblyInfo and the three Program.cs) | + +--- + +## E. Findings index (audit 2026-09-24 → part) + +### E.1 Bugs + +| # | Finding | Where | Part | +|---|---|---|---| +| 1 | `POST /users` is anonymous; anyone can create any user id | `UserEndpoints.cs:91` | P1.1 | +| 2 | `GET /playlists` lists every user's private playlists | `PlayListEndpoints.cs:22`, `GetPlayLists.cs` | P1.2 | +| 3 | `onlyPublic=false` default exposes other users' private playlists | `PlayListEndpoints.cs:135` | P1.2 | +| 4 | Playlist tracks endpoint ignores visibility | `GetPlayListTracks.cs` | P1.2 | +| 5 | Upload intent purpose not checked; same intent usable for picture and audio | `UploadIntentValidator.cs:45`, `CreateTrack.cs` | P1.3 | +| 6 | `FileType`/`ContentType` unrestricted; long extension overflows `varchar(64)` → 500 | upload validators | P1.4 | +| 7 | Gateway passes invalid/multi `Range` unclamped → anonymous preview bypass | `TicketValidationMiddleware.cs:84` | P1.5 | +| 8 | Album picture failures never reported; saga stuck in `Processing` | `ProcessingSlipFaultConsumer.cs:22` | P2.1 | +| 9 | `Temp*` config keys don't match `Temporal*` properties → ignored | both `appsettings.json` | P2.2 | +| 10 | Create slips without fault handling → entity `Pending` forever | `CreateTrackRoutingSlipBuilder`, picture-source builders | P2.3 | +| 11 | Duration rounded to int although column is double | `TranscodeAudioActivity.cs:58` | P2.4 | +| 12 | Empty audio folder key publishes "processed" without completing | `UpdateTrackAudioActivity.cs` | P2.5 | +| 13 | (to verify) picture-update events hit finalized sagas → `_error` queues | `Sagas/*` | P2.6 | +| 14 | "Not owner" returns 401 instead of 403 | `ErrorOrHttpExtensions.cs:32` + 10 handlers | P3.1 | +| 15 | `pageSize=0` → 500; no upper bound; `limit` unbounded | all list endpoints | P3.2 | +| 16 | `totalCount` computed before `Active` filter | `GetPlayListsByUserId.cs:41` | P3.3 | +| 17 | Removed/failed rows returned by public reads | many queries | P3.4 | +| 18 | Album/playlist covers 500 until processing finishes | `CreateAlbum.cs:63`, cover queries | P3.5 | +| 19 | Double-click like/follow/add → unique violation 500; playlist tracks can duplicate | likes, follows, `AddTrackTo*`, `Database.cs` | P3.6 | +| 20 | Position `Max+1` race; playlists 0-based without renumber vs albums 1-based with | `AddTrackTo*`, `RemoveTrackFrom*` | P3.7 | +| 21 | Playlist update can't clear description; names not trimmed; invalid enum accepted | `UpdatePlayList.cs`, `CreatePlayList.cs` | P3.8 | +| 22 | N+1 in mixes list | `GetMixesByUserId.cs` | P3.9 | +| 23 | Last-listened counts uncounted listens; 404 when none | `GetLastTrackListenedByUserId.cs` | P3.10 | +| 24 | Streak resets before the day ends | `GetListeningStats.cs` | P3.11 | +| 25 | `DeleteAlbum` leaves pictures in storage | `DeleteAlbum.cs:33` | P3.12 | +| 26 | `Path.Combine(sourceDirectory, file)` with a full path | `StorageService.cs:121` | P10.1 | +| 27 | Userinfo URL hardcoded to Zitadel path | `JwtBearerEventsHandler.cs` | P10.7 | +| 28 | Wrong log message "as processing" on delete | `DeleteTrack.cs:64` | P7.5 (log policy rewrite) | + +### E.2 Endpoints not used by the web-player (free to change) + +`GET /playlists`, `POST /users`, `GET /users/{id}`, `GET /users/{id}/is-following`, +`GET /albums/recent`, `GET /tracks/{id}`. `GET /users/{id}/listening-stats` has one call site; +check it before P1.6. + +### E.3 Quality findings + +| Finding | Part | +|---|---| +| No `.editorconfig`/analyzers; mixed namespaces, BOMs (2 mid-file), missing final newlines, stray spaces, blank lines after `{`, unsorted usings, missing `private` | P0 | +| Private field naming mixed (`_x` vs `x`), field shadowing ctor parameter in gateway middleware | P0, P13 | +| Album ↔ PlayList handler clones (upload, cover, create/update picture parts) | P8 | +| `AlbumRoutes`/`PlayListRoutes`/`TrackRoutes` + 3 `*PicturesSizes`; `ParentFolders` vs `ParentFolder`; identical `BuildTempPicturePath`/`BuildTempAudioPath` | P5 | +| `AlbumPictures` ≡ `PlayListPictures` | P6 | +| 15× `new ImageSize` | P5 | +| 9× cover-ids projection, 9× listens-count projection, 7× `StaticPagedList` | P7 | +| Search wrapper responses with swapped `HasNext/HasPrevious` order | P7 | +| Owner-check boilerplate in ~12 handlers | P7 | +| try/catch around save/publish in ~15 handlers; log-and-rethrow in infrastructure | P7, P10 | +| Inconsistent errors (`NotFound()` with/without description, one code), `Result.Success` vs `new Success()`, log levels | P7 | +| Handlers returning `ErrorOr` that can't fail; lazy `IEnumerable` result | P7 | +| Responses declared inside handler files; `CreateUserCommand.ToEntity` | P7, P1 | +| Inconsistent ordering; pagination clamp in one handler only | P3, P7 | +| Query that writes (`GetTrackStream`) | P9 | +| JSON converters in Application | P9 | +| Duplicated validation (tags, listening progress, genre parsing in endpoint) | P9 | +| Endpoint orchestration in `AddPlayListTrack`; album vs playlist add-track API mismatch | P9 | +| `UserHasTrack` duplicates ownership | P9 | +| Hardcoded Spanish mix titles in Application | P9 | +| `StorageService` writes to DB (`Upload`), typo `trasnsferUtility`, duplicated key building, `CancellationToken` not last, PascalCase parameter | P4, P10 | +| `AudioService` in `AudioTranscoderService.cs`; duplicated ffmpeg execution methods | P10 | +| Two job mechanisms (Hangfire + BackgroundService); job logic in infrastructure vs command; `CleanUp`/`Cleanup` naming; misleading `AddDailyMixGenerationJob` | P10 | +| 8 copies of validated-options registration; `serviceDescriptors` vs `services`; `…Options` vs `…Configuration` | P5 | +| 7 near-identical lifecycle activities; 3 picture-update activities; 2 picture-source builders; 3 failed consumers; routing-slip preamble ×10; outbox ×2; saga repo ×3 | P11 | +| `RoutingSlipVariableNames` namespace ≠ folder | P11 | +| Cover endpoint ×3; route parameter naming; no route constraints; raw return types; `New…` DTO prefix; duplicated upload validators; duplicated `SecuritySchemeId` | P12 | +| Worker: manual options (misses Album), no observability, `ValidateOnBuild = false`, stale appsettings | P13 | +| Entity noise: redundant `[Required]`, initializers, pragma vs `null!`, `required` navigation, mixed table naming, `PlaylistVisibility` casing | P6 | +| Dead code (see P4 list) and redundant package references | P4 | +| No tests for MassTransit activities, builders, sagas; `TestDatabase` drifts from `Database` | P14 | From b8524aa5a82a8b0dad25138acbe7042b8094b663 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 13:31:31 +0200 Subject: [PATCH 21/21] Update naming conventions in .editorconfig and docs.editorc --- backend/.editorconfig | 39 ++++++++++++++++++++++++--------------- backend/CLAUDE.md | 2 +- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/backend/.editorconfig b/backend/.editorconfig index e78ac4b..128436e 100644 --- a/backend/.editorconfig +++ b/backend/.editorconfig @@ -33,28 +33,37 @@ 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 +# Naming. Roslyn applies the first matching rule in file order, so the specific rules come first. +# Private fields are _camelCase whatever their modifiers (static, readonly, const). dotnet_naming_rule.private_fields.symbols = private_fields -dotnet_naming_rule.private_fields.style = camel_case +dotnet_naming_rule.private_fields.style = underscore_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_style.underscore_camel_case.capitalization = camel_case +dotnet_naming_style.underscore_camel_case.required_prefix = _ -dotnet_naming_rule.private_static_readonly.symbols = private_static_readonly -dotnet_naming_rule.private_static_readonly.style = pascal_case -dotnet_naming_rule.private_static_readonly.severity = warning -dotnet_naming_symbols.private_static_readonly.applicable_kinds = field -dotnet_naming_symbols.private_static_readonly.applicable_accessibilities = private -dotnet_naming_symbols.private_static_readonly.required_modifiers = static, readonly - -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 +# Non-private fields (constants, static readonly, etc.) are PascalCase. +dotnet_naming_rule.other_fields.symbols = other_fields +dotnet_naming_rule.other_fields.style = pascal_case +dotnet_naming_rule.other_fields.severity = warning +dotnet_naming_symbols.other_fields.applicable_kinds = field +dotnet_naming_symbols.other_fields.applicable_accessibilities = public, internal, protected, protected_internal, private_protected dotnet_naming_style.pascal_case.capitalization = pascal_case +dotnet_naming_rule.interfaces.symbols = interfaces +dotnet_naming_rule.interfaces.style = interface_style +dotnet_naming_rule.interfaces.severity = warning +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_style.interface_style.capitalization = pascal_case +dotnet_naming_style.interface_style.required_prefix = I + +dotnet_naming_rule.types_and_members.symbols = types_and_members +dotnet_naming_rule.types_and_members.style = pascal_case +dotnet_naming_rule.types_and_members.severity = warning +dotnet_naming_symbols.types_and_members.applicable_kinds = class, struct, enum, delegate, property, method, event +dotnet_naming_symbols.types_and_members.applicable_accessibilities = * + # CA1848/CA1873 would turn every log call into a [LoggerMessage] source-generated method; CA1716 flags the # EF-style IDatabase.Set() and the Musify.Application.Shared namespace, whose names are deliberate. dotnet_diagnostic.CA1848.severity = suggestion diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 5a29132..4a07a5a 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -20,7 +20,7 @@ dotnet format Musify.slnx --verify-no-changes ## Style - Block-scoped namespaces (`namespace X { }`; file-scoped `namespace X;` is not allowed), usings outside the namespace and sorted (`.editorconfig` enforces it). -- Private fields camelCase without underscore, constants and `static readonly` fields PascalCase, explicit accessibility. +- Private fields are `_camelCase` whatever their modifiers (including `static readonly` and `const`); non-private constants and static fields, types, methods and properties are PascalCase; interfaces start with `I`; explicit accessibility. Enforced by `.editorconfig` (IDE1006). - `ArgumentNullException.ThrowIfNull(x)` without `nameof`. - Use `PlayList` (not `Playlist`) in code; configuration POCOs end in `Configuration`; injected services are named after their type (`storageService`). - User-facing strings are in English (the web-player owns translation).