From 1ce88c7c1497323e68a7eb2565da6c71d578c1ef Mon Sep 17 00:00:00 2001 From: Mohamed jameel alabasy Date: Sun, 3 May 2026 07:57:49 +0300 Subject: [PATCH] Inject dependencies for upload and playlist tests Introduce constructor injection and test hooks to make upload and playlist logic testable. UploadTrackRemoteDataSource now accepts a Dio and a multipart-file factory (used instead of direct MultipartFile.fromFile). UploadTrackUseCase accepts fileFactory and fileExists overrides (with sensible defaults) and uses the injected checker instead of calling existsSync directly. UploadFormNotifier methods now accept an optional WidgetRef and allow overriding usecases/data sources/update/delete functions so tests can run without a sealed WidgetRef or real network. Also add a suite of playlist and upload unit tests covering datasource behavior, models, providers, and usecases. --- .../upload_track_remote_datasource.dart | 23 +- .../domain/usecases/upload_track_usecase.dart | 12 +- .../providers/upload_track_provider.dart | 72 +- .../playlist/local_saved_store_test.dart | 210 ++++ .../playlist_datasource_behavior_test.dart | 339 +++++ .../playlist/playlist_mock_data_test.dart | 166 +++ .../playlist_models_additional_test.dart | 86 ++ .../playlist/playlist_models_test.dart | 185 +++ .../playlist_provider_extended_test.dart | 695 +++++++++++ .../playlist_providers_behavior_test.dart | 413 +++++++ ...list_remote_datasource_discovery_test.dart | 1090 +++++++++++++++++ test/features/playlist/playlist_test.dart | 155 +++ .../playlist/playlist_usecases_test.dart | 338 +++++ .../upload_form_provider_test.dart | 545 +++++++++ .../upload_models_and_entities_test.dart | 111 ++ .../upload_remote_datasource_test.dart | 355 ++++++ .../upload_repository_impl_test.dart | 185 +++ .../upload_track_usecase_test.dart | 165 ++- 18 files changed, 5086 insertions(+), 59 deletions(-) create mode 100644 test/features/playlist/local_saved_store_test.dart create mode 100644 test/features/playlist/playlist_datasource_behavior_test.dart create mode 100644 test/features/playlist/playlist_mock_data_test.dart create mode 100644 test/features/playlist/playlist_models_additional_test.dart create mode 100644 test/features/playlist/playlist_models_test.dart create mode 100644 test/features/playlist/playlist_provider_extended_test.dart create mode 100644 test/features/playlist/playlist_providers_behavior_test.dart create mode 100644 test/features/playlist/playlist_remote_datasource_discovery_test.dart create mode 100644 test/features/playlist/playlist_usecases_test.dart create mode 100644 test/features/track_upload/upload_form_provider_test.dart create mode 100644 test/features/track_upload/upload_models_and_entities_test.dart create mode 100644 test/features/track_upload/upload_remote_datasource_test.dart create mode 100644 test/features/track_upload/upload_repository_impl_test.dart diff --git a/lib/features/track_upload/data/datasources/upload_track_remote_datasource.dart b/lib/features/track_upload/data/datasources/upload_track_remote_datasource.dart index 28c04dc8..d9cfd629 100644 --- a/lib/features/track_upload/data/datasources/upload_track_remote_datasource.dart +++ b/lib/features/track_upload/data/datasources/upload_track_remote_datasource.dart @@ -22,9 +22,22 @@ import 'package:flutter/foundation.dart'; /// - Does NOT return domain entities, only data models class UploadTrackRemoteDataSource { + UploadTrackRemoteDataSource({ + Dio? dio, + Future Function(String path, DioMediaType contentType)? + multipartFileFactory, + }) : _dio = dio ?? apiClient.dio, + _multipartFileFactory = + multipartFileFactory ?? + ((path, contentType) { + return MultipartFile.fromFile(path, contentType: contentType); + }); + // Uses the shared ApiClient your team leader built // Auth token is attached automatically — you don't touch it here - final Dio _dio = apiClient.dio; + final Dio _dio; + final Future Function(String path, DioMediaType contentType) + _multipartFileFactory; // ── Fetch Tags ───────────────────────────────────────────────────────────── @@ -85,10 +98,10 @@ class UploadTrackRemoteDataSource { 'genre': genre, 'is_public': isPublic, // Pass as boolean 'is_hidden': isHidden, - 'audio_file': await MultipartFile.fromFile( + 'audio_file': await _multipartFileFactory( // spec: 'audio_file' audioFile.path, - contentType: DioMediaType.parse(audioMime), + DioMediaType.parse(audioMime), ), }; @@ -103,10 +116,10 @@ class UploadTrackRemoteDataSource { // Cover image — spec: 'cover_image' if (artworkFile != null) { final artMime = lookupMimeType(artworkFile.path) ?? 'image/jpeg'; - fields['cover_image'] = await MultipartFile.fromFile( + fields['cover_image'] = await _multipartFileFactory( // spec: 'cover_image' artworkFile.path, - contentType: DioMediaType.parse(artMime), + DioMediaType.parse(artMime), ); } diff --git a/lib/features/track_upload/domain/usecases/upload_track_usecase.dart b/lib/features/track_upload/domain/usecases/upload_track_usecase.dart index 3f4250eb..7f169d45 100644 --- a/lib/features/track_upload/domain/usecases/upload_track_usecase.dart +++ b/lib/features/track_upload/domain/usecases/upload_track_usecase.dart @@ -28,8 +28,16 @@ import 'package:rythmify/features/track_upload/domain/repositories/upload_track_ class UploadTrackUseCase { final UploadTrackRepository repository; + final File Function(String path) fileFactory; + final bool Function(File file) fileExists; - const UploadTrackUseCase(this.repository); + const UploadTrackUseCase( + this.repository, { + this.fileFactory = File.new, + bool Function(File file)? fileExists, + }) : fileExists = fileExists ?? _defaultFileExists; + + static bool _defaultFileExists(File file) => file.existsSync(); Future> call({ required TrackDraft draft, @@ -47,7 +55,7 @@ class UploadTrackUseCase { // Double-check audio file still exists on device //commented for teting will uncomment later - if (!audioFile.existsSync()) { + if (!fileExists(audioFile)) { return const Left( FileFailure('Audio file no longer exists on your device.'), ); diff --git a/lib/features/track_upload/presentation/providers/upload_track_provider.dart b/lib/features/track_upload/presentation/providers/upload_track_provider.dart index 8b57d759..e73f1b53 100644 --- a/lib/features/track_upload/presentation/providers/upload_track_provider.dart +++ b/lib/features/track_upload/presentation/providers/upload_track_provider.dart @@ -212,7 +212,8 @@ class UploadFormNotifier extends Notifier { //added in backend integration phase Future startUpload({ - required WidgetRef ref, + WidgetRef? ref, + UploadTrackUseCase? useCaseOverride, required void Function(String trackId) onSuccess, required void Function(String error) onError, }) async { @@ -224,8 +225,14 @@ class UploadFormNotifier extends Notifier { draft.copyWith(status: UploadStatus.uploading, uploadProgress: 0.0), ); - // Get the usecase - final useCase = ref.read(uploadUseCaseProvider); + // Get the usecase. Production passes WidgetRef; tests can inject the + // use case directly because WidgetRef is sealed. + final useCase = useCaseOverride ?? ref?.read(uploadUseCaseProvider); + if (useCase == null) { + _updateDraft(state.draft!.copyWith(status: UploadStatus.error)); + onError('Upload service unavailable.'); + return; + } // Call usecase with draft and progress callback final result = await useCase( @@ -257,9 +264,14 @@ class UploadFormNotifier extends Notifier { } //added in back integration to fix 400 error when ftetching genres - Future fetchGenres(WidgetRef ref) async { + Future fetchGenres([ + WidgetRef? ref, + UploadTrackRemoteDataSource? dataSourceOverride, + ]) async { try { - final dataSource = ref.read(_uploadDataSourceProvider); + final dataSource = + dataSourceOverride ?? ref?.read(_uploadDataSourceProvider); + if (dataSource == null) return; final genres = await dataSource.fetchGenres(); state = state.copyWith(availableGenres: genres); debugPrint('Genres loaded: $genres'); @@ -268,9 +280,14 @@ class UploadFormNotifier extends Notifier { } } - Future fetchTags(WidgetRef ref) async { + Future fetchTags([ + WidgetRef? ref, + UploadTrackRemoteDataSource? dataSourceOverride, + ]) async { try { - final dataSource = ref.read(_uploadDataSourceProvider); + final dataSource = + dataSourceOverride ?? ref?.read(_uploadDataSourceProvider); + if (dataSource == null) return; final tags = await dataSource.fetchTags(); state = state.copyWith(availableTags: tags); debugPrint('Tags loaded: $tags'); @@ -305,7 +322,9 @@ class UploadFormNotifier extends Notifier { } Future handleUpdate({ - required WidgetRef ref, + WidgetRef? ref, + Future Function(String trackId, Map data)? + updateOverride, required void Function() onSuccess, required void Function(String error) onError, }) async { @@ -314,7 +333,20 @@ class UploadFormNotifier extends Notifier { state = state.copyWith(isLoading: true); - final updateTrack = ref.read(updateTrackUseCaseProvider); + final updateTrack = + updateOverride ?? + (ref == null + ? null + : (String trackId, Map data) => + ref.read(updateTrackUseCaseProvider).call(trackId, data)); + if (updateTrack == null) { + state = state.copyWith( + isLoading: false, + errorMessage: 'Update service unavailable.', + ); + onError('Update service unavailable.'); + return; + } final Map data = { "title": draft.title, @@ -330,7 +362,7 @@ class UploadFormNotifier extends Notifier { }; try { - await updateTrack.call(draft.trackId!, data); + await updateTrack(draft.trackId!, data); state = state.copyWith(isLoading: false); onSuccess(); } catch (e) { @@ -340,7 +372,8 @@ class UploadFormNotifier extends Notifier { } Future handleDelete({ - required WidgetRef ref, + WidgetRef? ref, + Future Function(String trackId)? deleteOverride, required void Function() onSuccess, required void Function(String error) onError, }) async { @@ -349,10 +382,23 @@ class UploadFormNotifier extends Notifier { state = state.copyWith(isLoading: true); - final deleteTrack = ref.read(deleteTrackUseCaseProvider); + final deleteTrack = + deleteOverride ?? + (ref == null + ? null + : (String trackId) => + ref.read(deleteTrackUseCaseProvider).call(trackId)); + if (deleteTrack == null) { + state = state.copyWith( + isLoading: false, + errorMessage: 'Delete service unavailable.', + ); + onError('Delete service unavailable.'); + return; + } try { - await deleteTrack.call(draft.trackId!); + await deleteTrack(draft.trackId!); state = state.copyWith(isLoading: false); onSuccess(); } catch (e) { diff --git a/test/features/playlist/local_saved_store_test.dart b/test/features/playlist/local_saved_store_test.dart new file mode 100644 index 00000000..64a4a697 --- /dev/null +++ b/test/features/playlist/local_saved_store_test.dart @@ -0,0 +1,210 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/playlist/data/local/local_saved_store.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + final savedAt = DateTime.utc(2024, 1, 2, 3, 4, 5); + final store = LocalSavedStore.instance; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('Saved value models', () { + test('round-trip mix, station, and track-radio JSON', () { + final mix = SavedMix( + mixId: 'mix-1', + title: 'Daily Mix', + ownerName: 'Rythmify', + coverUrl: 'cover.jpg', + trackCount: 12, + savedAt: savedAt, + ); + final station = SavedStation( + artistId: 'artist-1', + artistName: 'Artist', + stationName: 'Artist Radio', + coverUrl: null, + trackCount: 9, + savedAt: savedAt, + ); + final radio = SavedTrackRadio( + trackId: 'track-1', + playlistId: 'radio-playlist-1', + title: 'More like One', + coverUrl: 'radio.jpg', + trackCount: 20, + savedAt: savedAt, + ); + + expect(SavedMix.fromJson(mix.toJson()).toJson(), mix.toJson()); + expect( + SavedStation.fromJson(station.toJson()).toJson(), + station.toJson(), + ); + expect(SavedTrackRadio.fromJson(radio.toJson()).toJson(), radio.toJson()); + }); + + test('invalid savedAt falls back to a valid DateTime', () { + final mix = SavedMix.fromJson({ + 'mixId': 'mix-1', + 'title': 'Daily Mix', + 'ownerName': 'Rythmify', + 'coverUrl': null, + 'trackCount': null, + 'savedAt': 'not-a-date', + }); + + expect(mix.trackCount, 0); + expect(mix.savedAt, isA()); + }); + }); + + group('LocalSavedStore mixes', () { + test( + 'loads empty store, saves, replaces duplicates, and removes', + () async { + expect(await store.getMixes(), isEmpty); + expect(await store.isMixSaved('mix-1'), isFalse); + + await store.saveMix( + SavedMix( + mixId: 'mix-1', + title: 'Daily Mix', + ownerName: 'System', + coverUrl: 'old.jpg', + trackCount: 10, + savedAt: savedAt, + ), + ); + await store.saveMix( + SavedMix( + mixId: 'mix-1', + title: 'Updated Daily Mix', + ownerName: 'System', + coverUrl: 'new.jpg', + trackCount: 15, + savedAt: savedAt.add(const Duration(days: 1)), + ), + ); + + final mixes = await store.getMixes(); + expect(mixes, hasLength(1)); + expect(mixes.single.title, 'Updated Daily Mix'); + expect(mixes.single.trackCount, 15); + expect(await store.isMixSaved('mix-1'), isTrue); + + await store.removeMix('mix-1'); + expect(await store.getMixes(), isEmpty); + expect(await store.isMixSaved('mix-1'), isFalse); + }, + ); + + test('corrupted mix JSON is treated as an empty store', () async { + SharedPreferences.setMockInitialValues({'saved_mixes': '{bad json'}); + + expect(await store.getMixes(), isEmpty); + }); + }); + + group('LocalSavedStore stations', () { + test( + 'loads empty store, saves, replaces duplicates, and removes', + () async { + expect(await store.getStations(), isEmpty); + expect(await store.isStationSaved('artist-1'), isFalse); + + await store.saveStation( + SavedStation( + artistId: 'artist-1', + artistName: 'Artist', + stationName: 'Artist Radio', + coverUrl: null, + trackCount: 8, + savedAt: savedAt, + ), + ); + await store.saveStation( + SavedStation( + artistId: 'artist-1', + artistName: 'Artist', + stationName: 'Artist Updated Radio', + coverUrl: 'cover.jpg', + trackCount: 11, + savedAt: savedAt, + ), + ); + + final stations = await store.getStations(); + expect(stations, hasLength(1)); + expect(stations.single.stationName, 'Artist Updated Radio'); + expect(stations.single.coverUrl, 'cover.jpg'); + expect(await store.isStationSaved('artist-1'), isTrue); + + await store.removeStation('artist-1'); + expect(await store.getStations(), isEmpty); + expect(await store.isStationSaved('artist-1'), isFalse); + }, + ); + + test('corrupted station JSON is treated as an empty store', () async { + SharedPreferences.setMockInitialValues({'saved_stations': '[1]'}); + + expect(await store.getStations(), isEmpty); + }); + }); + + group('LocalSavedStore track radios', () { + test( + 'loads empty store, saves, replaces duplicates, maps playlist id, and removes', + () async { + expect(await store.getTrackRadios(), isEmpty); + expect(await store.isTrackRadioSaved('track-1'), isFalse); + expect(await store.trackRadioPlaylistId('track-1'), isNull); + + await store.saveTrackRadio( + SavedTrackRadio( + trackId: 'track-1', + playlistId: 'radio-playlist-1', + title: 'More like One', + coverUrl: null, + trackCount: 20, + savedAt: savedAt, + ), + ); + await store.saveTrackRadio( + SavedTrackRadio( + trackId: 'track-1', + playlistId: 'radio-playlist-2', + title: 'More like One Updated', + coverUrl: 'cover.jpg', + trackCount: 21, + savedAt: savedAt, + ), + ); + + final radios = await store.getTrackRadios(); + expect(radios, hasLength(1)); + expect(radios.single.playlistId, 'radio-playlist-2'); + expect(await store.isTrackRadioSaved('track-1'), isTrue); + expect(await store.trackRadioPlaylistId('track-1'), 'radio-playlist-2'); + + await store.removeTrackRadio('track-1'); + expect(await store.getTrackRadios(), isEmpty); + expect(await store.trackRadioPlaylistId('track-1'), isNull); + }, + ); + + test('corrupted track radio JSON is treated as an empty store', () async { + SharedPreferences.setMockInitialValues({ + 'saved_track_radios': jsonEncode([ + {'trackId': 'missing-required-fields'}, + ]), + }); + + expect(await store.getTrackRadios(), isEmpty); + }); + }); +} diff --git a/test/features/playlist/playlist_datasource_behavior_test.dart b/test/features/playlist/playlist_datasource_behavior_test.dart new file mode 100644 index 00000000..3b88987a --- /dev/null +++ b/test/features/playlist/playlist_datasource_behavior_test.dart @@ -0,0 +1,339 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/playlist/data/datasources/playlist_remote_datasource.dart'; +import 'package:rythmify/features/playlist/domain/entities/playlist_entity.dart'; + +class MockDio extends Mock implements Dio {} + +Map _playlistJson({ + String id = 'playlist-1', + String name = 'Night Drive', + bool isPublic = true, + String subtype = 'playlist', +}) { + return { + 'playlist_id': id, + 'name': name, + 'owner_user_id': 'user-1', + 'is_public': isPublic, + 'subtype': subtype, + 'type': 'regular', + 'track_count': 2, + 'created_at': '2024-01-02T03:04:05Z', + }; +} + +Map _trackJson(String id, int position) { + return { + 'track_id': id, + 'title': 'Track $id', + 'artist_name': 'Artist $id', + 'duration': 90, + 'position': position, + 'is_public': true, + }; +} + +Response> _jsonResponse( + String path, + Map data, +) { + return Response>( + requestOptions: RequestOptions(path: path), + statusCode: 200, + data: data, + ); +} + +void main() { + late MockDio dio; + late PlaylistRemoteDatasource datasource; + + setUp(() { + dio = MockDio(); + datasource = PlaylistRemoteDatasource(dio); + }); + + group('PlaylistRemoteDatasource core playlist calls', () { + test( + 'fetchMyPlaylists sends mine/filter/limit and marks created lists owned', + () async { + when( + () => dio.get>( + '/playlists', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( + (_) async => _jsonResponse('/playlists', { + 'data': { + 'items': [_playlistJson(id: 'owned-1')], + }, + }), + ); + + final result = await datasource.fetchMyPlaylists(); + + expect(result, hasLength(1)); + expect(result.first.id, 'owned-1'); + expect(result.first.isOwned, isTrue); + verify( + () => dio.get>( + '/playlists', + queryParameters: {'mine': true, 'filter': 'created', 'limit': 50}, + ), + ).called(1); + }, + ); + + test( + 'fetchMyPlaylists supports liked/album filters without ownership', + () async { + when( + () => dio.get>( + '/playlists', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( + (_) async => _jsonResponse('/playlists', { + 'data': { + 'items': [_playlistJson(id: 'album-1', subtype: 'album')], + }, + }), + ); + + final result = await datasource.fetchMyPlaylists( + filter: 'liked', + subtype: 'album', + ); + + expect(result.single.id, 'album-1'); + expect(result.single.type, PlaylistType.album); + expect(result.single.isOwned, isFalse); + verify( + () => dio.get>( + '/playlists', + queryParameters: { + 'mine': true, + 'filter': 'liked', + 'limit': 50, + 'subtype': 'album', + }, + ), + ).called(1); + }, + ); + + test( + 'createPlaylist posts public/private metadata and parses response', + () async { + when( + () => dio.post>( + '/playlists', + data: any(named: 'data'), + ), + ).thenAnswer( + (_) async => _jsonResponse('/playlists', { + 'data': _playlistJson( + id: 'new-1', + name: 'Private', + isPublic: false, + ), + }), + ); + + final result = await datasource.createPlaylist( + name: 'Private', + isPublic: false, + ); + + expect(result.id, 'new-1'); + expect(result.isPublic, isFalse); + expect(result.isOwned, isTrue); + verify( + () => dio.post>( + '/playlists', + data: { + 'name': 'Private', + 'is_public': false, + 'subtype': 'playlist', + }, + ), + ).called(1); + }, + ); + + test('deletePlaylist calls the correct endpoint', () async { + when(() => dio.delete('/playlists/delete-me')).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/playlists/delete-me'), + statusCode: 204, + ), + ); + + await datasource.deletePlaylist('delete-me'); + + verify(() => dio.delete('/playlists/delete-me')).called(1); + }); + + test( + 'rethrows DioException for unauthorized or invalid playlist access', + () async { + final error = DioException( + requestOptions: RequestOptions(path: '/playlists/bad-id'), + response: Response( + requestOptions: RequestOptions(path: '/playlists/bad-id'), + statusCode: 403, + data: { + 'error': {'code': 'PLAYLIST_ACCESS_DENIED'}, + }, + ), + ); + when( + () => dio.get>( + '/playlists/bad-id', + queryParameters: any(named: 'queryParameters'), + ), + ).thenThrow(error); + + expect( + () => datasource.fetchPlaylistDetail('bad-id'), + throwsA(isA()), + ); + }, + ); + }); + + group('PlaylistRemoteDatasource track management', () { + test( + 'fetchPlaylistTracks walks pagination and preserves all pages', + () async { + when( + () => dio.get>( + '/playlists/playlist-1/tracks', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer((invocation) async { + final query = + invocation.namedArguments[#queryParameters] + as Map; + final page = query['page'] as int; + return _jsonResponse('/playlists/playlist-1/tracks', { + 'data': { + 'tracks': page == 1 + ? [_trackJson('t2', 2), _trackJson('t1', 1)] + : [_trackJson('t3', 3)], + 'pagination': {'has_next': page == 1}, + }, + }); + }); + + final result = await datasource.fetchPlaylistTracks('playlist-1'); + + expect(result.map((t) => t.id), ['t1', 't2', 't3']); + verify( + () => dio.get>( + '/playlists/playlist-1/tracks', + queryParameters: {'page': 1, 'limit': 20}, + ), + ).called(1); + verify( + () => dio.get>( + '/playlists/playlist-1/tracks', + queryParameters: {'page': 2, 'limit': 20}, + ), + ).called(1); + }, + ); + + test( + 'addTrackToPlaylist sends position and treats duplicates as success', + () async { + when( + () => dio.post( + '/playlists/playlist-1/tracks', + data: any(named: 'data'), + ), + ).thenThrow( + DioException( + requestOptions: RequestOptions( + path: '/playlists/playlist-1/tracks', + ), + response: Response( + requestOptions: RequestOptions( + path: '/playlists/playlist-1/tracks', + ), + statusCode: 409, + ), + ), + ); + + await datasource.addTrackToPlaylist( + playlistId: 'playlist-1', + trackId: 'track-1', + position: 2, + ); + + verify( + () => dio.post( + '/playlists/playlist-1/tracks', + data: {'track_id': 'track-1', 'position': 2}, + ), + ).called(1); + }, + ); + + test( + 'removeTrackFromPlaylist and reorder call correct endpoints', + () async { + when( + () => dio.delete('/playlists/playlist-1/tracks/track-1'), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions( + path: '/playlists/playlist-1/tracks/track-1', + ), + statusCode: 204, + ), + ); + when( + () => dio.patch( + '/playlists/playlist-1/tracks/reorder', + data: any(named: 'data'), + ), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions( + path: '/playlists/playlist-1/tracks/reorder', + ), + statusCode: 200, + ), + ); + + await datasource.removeTrackFromPlaylist( + playlistId: 'playlist-1', + trackId: 'track-1', + ); + await datasource.reorderPlaylistTracks( + playlistId: 'playlist-1', + orderedTrackIds: ['track-2', 'track-1'], + ); + + verify( + () => dio.delete('/playlists/playlist-1/tracks/track-1'), + ).called(1); + verify( + () => dio.patch( + '/playlists/playlist-1/tracks/reorder', + data: { + 'items': [ + {'track_id': 'track-2', 'position': 1}, + {'track_id': 'track-1', 'position': 2}, + ], + }, + ), + ).called(1); + }, + ); + }); +} diff --git a/test/features/playlist/playlist_mock_data_test.dart b/test/features/playlist/playlist_mock_data_test.dart new file mode 100644 index 00000000..b2fd6354 --- /dev/null +++ b/test/features/playlist/playlist_mock_data_test.dart @@ -0,0 +1,166 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/playlist/data/mock/playlist_mock_data.dart'; +import 'package:rythmify/features/playlist/domain/entities/playlist_entity.dart'; +import 'package:rythmify/features/playlist/domain/entities/playlist_track.dart'; + +void main() { + final cache = PlaylistMockData.instance; + + final playlist = PlaylistEntity( + id: 'playlist-1', + name: 'Playlist', + ownerName: 'Owner', + ownerId: 'owner-1', + isPublic: true, + type: PlaylistType.playlist, + trackCount: 0, + totalDuration: Duration.zero, + createdAt: DateTime(2024, 1, 1), + ); + final album = playlist.copyWith(id: 'album-1', type: PlaylistType.album); + final station = playlist.copyWith( + id: 'station-1', + type: PlaylistType.station, + ); + final track = PlaylistTrack( + id: 'track-1', + title: 'One', + artistName: 'Artist', + duration: const Duration(seconds: 30), + playCount: 1, + position: 99, + ); + + setUp(() { + cache.syncFromBackend([]); + }); + + test('syncFromBackend replaces playlists and prunes orphaned tracks', () { + cache.syncFromBackend([playlist]); + cache.addTrack(playlistId: 'playlist-1', track: track); + + cache.syncFromBackend([album, station]); + + expect(cache.getById('playlist-1'), isNull); + expect(cache.getTracksFor('playlist-1'), isEmpty); + expect(cache.getAlbums().single.id, 'album-1'); + expect(cache.getStations().single.id, 'station-1'); + }); + + test('createWithId is idempotent and stores initial empty tracks', () { + final first = cache.createWithId( + id: 'playlist-1', + name: 'First', + isPublic: true, + ownerName: 'Owner', + ownerId: 'owner-1', + ); + final second = cache.createWithId( + id: 'playlist-1', + name: 'Second', + isPublic: false, + ); + + expect(identical(first, second), isTrue); + expect(cache.getMyPlaylists(), hasLength(1)); + expect(cache.getTracksFor('playlist-1'), isEmpty); + }); + + test('update and cover changes are no-ops for missing playlists', () { + cache.syncFromBackend([playlist]); + + cache.update( + playlistId: 'playlist-1', + name: 'Updated', + isPublic: false, + description: 'desc', + ); + cache.updateCoverImage(playlistId: 'playlist-1', localPath: 'cover.jpg'); + cache.update(playlistId: 'missing', name: 'Missing', isPublic: true); + cache.updateCoverImage(playlistId: 'missing', localPath: 'missing.jpg'); + + final updated = cache.getById('playlist-1')!; + expect(updated.name, 'Updated'); + expect(updated.isPublic, isFalse); + expect(updated.description, 'desc'); + expect(updated.coverUrl, 'cover.jpg'); + }); + + test( + 'track add prevents duplicates, repositions after removal, and updates totals', + () { + cache.syncFromBackend([playlist]); + + cache.addTrack(playlistId: 'playlist-1', track: track); + cache.addTrack( + playlistId: 'playlist-1', + track: track.copyWith(position: 1), + ); + cache.addTrack( + playlistId: 'playlist-1', + track: const PlaylistTrack( + id: 'track-2', + title: 'Two', + artistName: 'Artist', + duration: Duration(seconds: 45), + playCount: 1, + position: 4, + ), + ); + + expect(cache.getTracksFor('playlist-1').map((t) => t.position), [1, 2]); + expect(cache.getById('playlist-1')!.trackCount, 2); + expect( + cache.getById('playlist-1')!.totalDuration, + const Duration(seconds: 75), + ); + + cache.removeTrack(playlistId: 'playlist-1', trackId: 'track-1'); + expect(cache.getTracksFor('playlist-1').single.position, 1); + expect(cache.getById('playlist-1')!.trackCount, 1); + + cache.removeTrack(playlistId: 'missing', trackId: 'track-2'); + cache.addTrack(playlistId: 'missing', track: track); + expect(cache.getTracksFor('missing'), hasLength(1)); + }, + ); + + test('delete removes playlists and tracks', () { + cache.syncFromBackend([playlist]); + cache.addTrack(playlistId: 'playlist-1', track: track); + + cache.delete('playlist-1'); + + expect(cache.getById('playlist-1'), isNull); + expect(cache.getTracksFor('playlist-1'), isEmpty); + }); + + test( + 'conversions update type and handle missing ids using existing first playlist', + () { + cache.syncFromBackend([playlist]); + + expect(cache.convertToAlbum('playlist-1').type, PlaylistType.album); + expect(cache.convertToPlaylist('playlist-1').type, PlaylistType.playlist); + expect( + cache + .convertToStation('playlist-1', seedArtistName: 'Seed') + .seedArtistName, + 'Seed', + ); + expect(cache.convertToAlbum('missing').id, 'playlist-1'); + expect(cache.convertToPlaylist('missing').id, 'playlist-1'); + expect(cache.convertToStation('missing').id, 'playlist-1'); + }, + ); + + test('createStation creates a local station seeded by the track artist', () { + final station = cache.createStation(seedTrack: track); + + expect(station.type, PlaylistType.station); + expect(station.name, 'Artist Radio'); + expect(station.seedArtistName, 'Artist'); + expect(station.coverUrl, isNull); + expect(cache.getStations().single.id, station.id); + }); +} diff --git a/test/features/playlist/playlist_models_additional_test.dart b/test/features/playlist/playlist_models_additional_test.dart new file mode 100644 index 00000000..39f8e2b2 --- /dev/null +++ b/test/features/playlist/playlist_models_additional_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/playlist/data/models/station_model.dart'; +import 'package:rythmify/features/playlist/domain/entities/collection_type.dart'; +import 'package:rythmify/features/playlist/domain/entities/playlist_entity.dart'; + +void main() { + group('StationModel', () { + test('maps a complete station response into a station playlist entity', () { + final station = StationModel.fromJson({ + 'id': 'station-1', + 'name': 'Artist Radio', + 'cover_image': 'cover.jpg', + 'track_count': 18, + 'seed_artist': {'user_id': 'artist-1', 'display_name': 'Artist Name'}, + }); + + expect(station.id, 'station-1'); + expect(station.name, 'Artist Radio'); + expect(station.type, PlaylistType.station); + expect(station.isPublic, isTrue); + expect(station.ownerId, 'artist-1'); + expect(station.ownerName, 'Artist Name'); + expect(station.seedArtistName, 'Artist Name'); + expect(station.description, "Radio station based on Artist Name's music"); + expect(station.coverUrl, 'cover.jpg'); + expect(station.trackCount, 18); + }); + + test('uses defensive defaults for missing seed artist fields', () { + final station = StationModel.fromJson({ + 'id': 'station-2', + 'name': 'Unknown Radio', + }); + + expect(station.ownerId, ''); + expect(station.ownerName, 'Unknown Artist'); + expect(station.trackCount, 0); + expect(station.coverUrl, isNull); + expect(station.seedArtistName, 'Unknown Artist'); + }); + + test('maps station lists without losing order', () { + final stations = StationModel.fromJsonList([ + { + 'id': 'station-1', + 'name': 'One Radio', + 'seed_artist': {'display_name': 'One'}, + }, + { + 'id': 'station-2', + 'name': 'Two Radio', + 'seed_artist': {'display_name': 'Two'}, + }, + ]); + + expect(stations.map((s) => s.id), ['station-1', 'station-2']); + }); + }); + + group('CollectionType mapping', () { + test('groups every album-like backend subtype as album', () { + expect(collectionTypeFromSubtype('album'), CollectionType.album); + expect(collectionTypeFromSubtype('ep'), CollectionType.album); + expect(collectionTypeFromSubtype('single'), CollectionType.album); + expect(collectionTypeFromSubtype('compilation'), CollectionType.album); + }); + + test('falls back unknown and null subtypes to playlist', () { + expect(collectionTypeFromSubtype('playlist'), CollectionType.playlist); + expect(collectionTypeFromSubtype('secret'), CollectionType.playlist); + expect(collectionTypeFromSubtype(null), CollectionType.playlist); + }); + + test('serializes collection types to supported backend subtypes', () { + expect(subtypeFromCollectionType(CollectionType.album), 'album'); + expect(subtypeFromCollectionType(CollectionType.playlist), 'playlist'); + expect(subtypeFromCollectionType(CollectionType.station), 'playlist'); + }); + + test('provides user-facing labels for every collection type', () { + expect(labelForCollectionType(CollectionType.playlist), 'Playlist'); + expect(labelForCollectionType(CollectionType.album), 'Album'); + expect(labelForCollectionType(CollectionType.station), 'Station'); + }); + }); +} diff --git a/test/features/playlist/playlist_models_test.dart b/test/features/playlist/playlist_models_test.dart new file mode 100644 index 00000000..8da45d15 --- /dev/null +++ b/test/features/playlist/playlist_models_test.dart @@ -0,0 +1,185 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/playlist/data/models/playlist_model.dart'; +import 'package:rythmify/features/playlist/data/models/playlist_track_model.dart'; +import 'package:rythmify/features/playlist/domain/entities/playlist_entity.dart'; + +Map _playlistJson({ + String id = 'playlist-1', + String name = 'Night Drive', + String ownerId = 'user-1', + String subtype = 'playlist', + String type = 'regular', + bool? isPublic = true, + String? releaseDate, + int? trackCount = 3, + String? coverImage = 'https://cdn/cover.jpg', + bool? isLiked = false, +}) { + return { + 'playlist_id': id, + 'name': name, + 'owner_user_id': ownerId, + 'subtype': subtype, + 'type': type, + 'is_public': isPublic, + 'track_count': trackCount, + 'created_at': '2024-01-02T03:04:05Z', + 'cover_image': coverImage, + 'description': 'Late-night playlist', + 'like_count': 7, + 'repost_count': 2, + 'is_liked_by_me': isLiked, + 'release_date': releaseDate, + }; +} + +Map _trackJson({ + String id = 'track-1', + String title = 'Intro', + String artist = 'Artist', + int duration = 125, + int position = 1, + bool isPublic = true, + String? deletedAt, +}) { + return { + 'track_id': id, + 'title': title, + 'artist_name': artist, + 'duration': duration, + 'position': position, + 'cover_image': 'https://cdn/$id.jpg', + 'is_public': isPublic, + 'deleted_at': deletedAt, + }; +} + +void main() { + group('PlaylistModel', () { + test('maps a full playlist payload including ownership and engagement', () { + final result = PlaylistModel.fromJson( + _playlistJson(isPublic: false, isLiked: true), + isOwned: true, + ); + + expect(result.id, 'playlist-1'); + expect(result.name, 'Night Drive'); + expect(result.ownerId, 'user-1'); + expect(result.isPublic, isFalse); + expect(result.type, PlaylistType.playlist); + expect(result.trackCount, 3); + expect(result.coverUrl, 'https://cdn/cover.jpg'); + expect(result.description, 'Late-night playlist'); + expect(result.likeCount, 7); + expect(result.repostCount, 2); + expect(result.isLiked, isTrue); + expect(result.isOwned, isTrue); + }); + + test( + 'defaults nullable backend fields without changing access semantics', + () { + final result = PlaylistModel.fromJson( + _playlistJson(isPublic: null, trackCount: null, coverImage: null) + ..remove('is_liked_by_me'), + ); + + expect(result.isPublic, isTrue); + expect(result.trackCount, 0); + expect(result.coverUrl, isNull); + expect(result.isLiked, isFalse); + }, + ); + + test('maps album subtypes and release years', () { + final result = PlaylistModel.fromJson( + _playlistJson(subtype: 'album', releaseDate: '2026-04-12'), + ); + + expect(result.type, PlaylistType.album); + expect(result.typeLabel, 'Album'); + expect(result.releaseYear, 2026); + }); + + test('detects generated mixes and track radios from backend markers', () { + final mix = PlaylistModel.fromJson( + _playlistJson(id: 'mix-1', subtype: 'curated_daily'), + ); + final radio = PlaylistModel.fromJson( + _playlistJson(id: 'radio-1', type: 'track_radio'), + ); + + expect(mix.isGeneratedMix, isTrue); + expect(mix.typeLabel, 'Mix'); + expect(radio.isTrackRadio, isTrue); + expect(radio.typeLabel, 'Radio'); + }); + + test('fromJsonListOwned marks every created playlist as owned', () { + final result = PlaylistModel.fromJsonListOwned([ + _playlistJson(id: 'p1'), + _playlistJson(id: 'p2', isPublic: false), + ]); + + expect(result.map((p) => p.id), ['p1', 'p2']); + expect(result.every((p) => p.isOwned), isTrue); + }); + + test( + 'toCreateJson sends the API contract for public and private lists', + () { + expect(PlaylistModel.toCreateJson(name: 'Secret', isPublic: false), { + 'name': 'Secret', + 'is_public': false, + 'subtype': 'playlist', + }); + expect( + PlaylistModel.toCreateJson( + name: 'Album', + isPublic: true, + subtype: 'album', + ), + {'name': 'Album', 'is_public': true, 'subtype': 'album'}, + ); + }, + ); + }); + + group('PlaylistTrackModel', () { + test('maps track fields and formats duration through entity behavior', () { + final result = PlaylistTrackModel.fromJson(_trackJson()); + + expect(result.id, 'track-1'); + expect(result.title, 'Intro'); + expect(result.artistName, 'Artist'); + expect(result.duration, const Duration(seconds: 125)); + expect(result.formattedDuration, '2:05'); + expect(result.position, 1); + expect(result.coverUrl, 'https://cdn/track-1.jpg'); + expect(result.isUnavailable, isFalse); + }); + + test('marks private and deleted tracks unavailable', () { + final private = PlaylistTrackModel.fromJson( + _trackJson(id: 'private', isPublic: false), + ); + final deleted = PlaylistTrackModel.fromJson( + _trackJson(id: 'deleted', deletedAt: '2024-02-01T00:00:00Z'), + ); + + expect(private.isUnavailable, isTrue); + expect(deleted.isUnavailable, isTrue); + }); + + test('sorts tracks by playlist position and allows empty playlists', () { + final result = PlaylistTrackModel.fromJsonList([ + _trackJson(id: 'last', position: 3), + _trackJson(id: 'first', position: 1), + _trackJson(id: 'middle', position: 2), + ]); + + expect(result.map((t) => t.id), ['first', 'middle', 'last']); + expect(PlaylistTrackModel.fromJsonList([]), isEmpty); + }); + }); +} diff --git a/test/features/playlist/playlist_provider_extended_test.dart b/test/features/playlist/playlist_provider_extended_test.dart new file mode 100644 index 00000000..5c35f6cf --- /dev/null +++ b/test/features/playlist/playlist_provider_extended_test.dart @@ -0,0 +1,695 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/core/domain/entities/track.dart'; +import 'package:rythmify/features/authentication/domain/entities/user_entity.dart'; +import 'package:rythmify/features/authentication/presentation/providers/auth_provider.dart'; +import 'package:rythmify/features/authentication/presentation/providers/auth_state.dart'; +import 'package:rythmify/features/playlist/data/datasources/playlist_remote_datasource.dart'; +import 'package:rythmify/features/playlist/data/mock/playlist_mock_data.dart'; +import 'package:rythmify/features/playlist/domain/entities/playlist_entity.dart'; +import 'package:rythmify/features/playlist/domain/entities/playlist_track.dart'; +import 'package:rythmify/features/playlist/presentation/providers/playlist_provider.dart'; + +class MockPlaylistRemoteDatasource extends Mock + implements PlaylistRemoteDatasource {} + +class TestAuthNotifier extends AuthNotifier { + TestAuthNotifier([this.initial = const AuthUnauthenticated()]); + + final AuthState initial; + + @override + AuthState build() => initial; +} + +DioException _dioError(String path, [int statusCode = 500]) { + return DioException( + requestOptions: RequestOptions(path: path), + response: Response( + requestOptions: RequestOptions(path: path), + statusCode: statusCode, + ), + ); +} + +final _playlist = PlaylistEntity( + id: 'playlist-1', + name: 'Night Drive', + ownerName: 'Owner', + ownerId: 'owner-1', + isPublic: true, + type: PlaylistType.playlist, + trackCount: 2, + totalDuration: const Duration(minutes: 7), + createdAt: DateTime(2024, 1, 1), +); + +final _album = _playlist.copyWith( + id: 'album-1', + name: 'Album', + type: PlaylistType.album, +); + +final _station = _playlist.copyWith( + id: 'station-1', + name: 'Artist Radio', + type: PlaylistType.station, + seedArtistName: 'artist-1', +); + +final _trackRadio = _playlist.copyWith( + id: 'radio-1', + name: 'More like Track', + isTrackRadio: true, +); + +final _mix = _playlist.copyWith( + id: 'mix-1', + name: 'Daily Mix', + isGeneratedMix: true, +); + +final _tracks = [ + PlaylistTrack( + id: 'track-1', + title: 'One', + artistName: 'Artist', + duration: const Duration(minutes: 3), + playCount: 10, + position: 1, + coverUrl: 'cover.jpg', + ), + PlaylistTrack( + id: 'track-2', + title: 'Two', + artistName: 'Artist', + duration: const Duration(minutes: 4), + playCount: 20, + position: 2, + ), +]; + +PlaylistTrack _suggestionTrack() => const PlaylistTrack( + id: 'suggestion-1', + title: 'Two', + artistName: 'Artist', + duration: Duration(minutes: 4), + playCount: 20, + position: 0, +); + +ProviderContainer _container( + MockPlaylistRemoteDatasource datasource, { + AuthState authState = const AuthUnauthenticated(), +}) { + final container = ProviderContainer( + overrides: [ + playlistDatasourceProvider.overrideWithValue(datasource), + authProvider.overrideWith(() => TestAuthNotifier(authState)), + ], + ); + addTearDown(container.dispose); + return container; +} + +void main() { + late MockPlaylistRemoteDatasource datasource; + + setUp(() { + datasource = MockPlaylistRemoteDatasource(); + PlaylistMockData.instance.syncFromBackend([]); + }); + + group('PlaylistListNotifier additional behavior', () { + test( + 'loadLikedAlbums uses liked filter and reports API failures', + () async { + when( + () => datasource.fetchMyPlaylists(filter: 'liked'), + ).thenAnswer((_) async => [_album]); + + final container = _container(datasource); + await container.read(playlistListProvider.notifier).loadLikedAlbums(); + + expect( + container.read(playlistListProvider).playlists.single.id, + 'album-1', + ); + + when( + () => datasource.fetchMyPlaylists(filter: 'liked'), + ).thenThrow(_dioError('/playlists', 403)); + await container.read(playlistListProvider.notifier).loadLikedAlbums(); + + final failed = container.read(playlistListProvider); + expect(failed.playlists, isEmpty); + expect(failed.error, 'Could not load liked albums'); + }, + ); + + test( + 'updateCoverImage updates cached state without making a network call', + () { + PlaylistMockData.instance.syncFromBackend([_playlist]); + + final container = _container(datasource); + container + .read(playlistListProvider.notifier) + .updateCoverImage(playlistId: 'playlist-1', localPath: 'local.jpg'); + + final state = container.read(playlistListProvider); + expect(state.playlists.single.coverUrl, 'local.jpg'); + verifyNever( + () => datasource.updatePlaylist( + playlistId: any(named: 'playlistId'), + name: any(named: 'name'), + isPublic: any(named: 'isPublic'), + ), + ); + }, + ); + + test( + 'copyPlaylist copies regular playlists and skips tracks rejected by the API', + () async { + final created = _playlist.copyWith(id: 'copy-1', name: 'Copy'); + when( + () => datasource.createPlaylist(name: 'Copy', isPublic: false), + ).thenAnswer((_) async => created); + when( + () => datasource.fetchPlaylistTracks('playlist-1'), + ).thenAnswer((_) async => _tracks); + when( + () => datasource.addTrackToPlaylist( + playlistId: 'copy-1', + trackId: 'track-1', + ), + ).thenAnswer((_) async {}); + when( + () => datasource.addTrackToPlaylist( + playlistId: 'copy-1', + trackId: 'track-2', + ), + ).thenThrow(_dioError('/playlists/copy-1/tracks', 409)); + when( + () => datasource.fetchMyPlaylists(filter: 'created'), + ).thenAnswer((_) async => [created]); + + final container = _container(datasource); + final id = await container + .read(playlistListProvider.notifier) + .copyPlaylist( + 'playlist-1', + overrideName: 'Copy', + overridePublic: false, + sourceEntity: _playlist, + ); + + expect(id, 'copy-1'); + verify(() => datasource.fetchPlaylistTracks('playlist-1')).called(1); + verify( + () => datasource.addTrackToPlaylist( + playlistId: 'copy-1', + trackId: 'track-1', + ), + ).called(1); + verify( + () => datasource.addTrackToPlaylist( + playlistId: 'copy-1', + trackId: 'track-2', + ), + ).called(1); + }, + ); + + test( + 'copyPlaylist uses station, radio, and mix-specific track sources', + () async { + final created = _playlist.copyWith( + id: 'copy-2', + name: 'Copy of Source', + ); + when( + () => datasource.createPlaylist( + name: any(named: 'name'), + isPublic: any(named: 'isPublic'), + ), + ).thenAnswer((_) async => created); + when( + () => datasource.addTrackToPlaylist( + playlistId: any(named: 'playlistId'), + trackId: any(named: 'trackId'), + ), + ).thenAnswer((_) async {}); + when( + () => datasource.fetchMyPlaylists(filter: 'created'), + ).thenAnswer((_) async => [created]); + when( + () => datasource.fetchStationTracks('artist-1', limit: 50), + ).thenAnswer((_) async => [_tracks.first]); + when( + () => datasource.fetchRadioTracks('radio-1'), + ).thenAnswer((_) async => []); + when( + () => datasource.fetchPlaylistTracks('radio-1'), + ).thenAnswer((_) async => [_tracks.first]); + when( + () => datasource.fetchMixTracks('mix-1'), + ).thenAnswer((_) async => []); + when( + () => datasource.fetchPlaylistTracks('mix-1'), + ).thenAnswer((_) async => [_tracks.last]); + + final container = _container(datasource); + final notifier = container.read(playlistListProvider.notifier); + + expect( + await notifier.copyPlaylist('station-1', sourceEntity: _station), + 'copy-2', + ); + expect( + await notifier.copyPlaylist('radio-1', sourceEntity: _trackRadio), + 'copy-2', + ); + expect( + await notifier.copyPlaylist('mix-1', sourceEntity: _mix), + 'copy-2', + ); + + verify( + () => datasource.fetchStationTracks('artist-1', limit: 50), + ).called(1); + verify(() => datasource.fetchRadioTracks('radio-1')).called(1); + verify(() => datasource.fetchPlaylistTracks('radio-1')).called(1); + verify(() => datasource.fetchMixTracks('mix-1')).called(1); + verify(() => datasource.fetchPlaylistTracks('mix-1')).called(1); + }, + ); + + test('copyPlaylist returns null when playlist creation fails', () async { + when( + () => datasource.createPlaylist( + name: any(named: 'name'), + isPublic: any(named: 'isPublic'), + ), + ).thenThrow(_dioError('/playlists', 422)); + + final container = _container(datasource); + final id = await container + .read(playlistListProvider.notifier) + .copyPlaylist('playlist-1', sourceEntity: _playlist); + + expect(id, isNull); + }); + + test( + 'conversion methods call datasource/cache paths and tolerate failures', + () async { + PlaylistMockData.instance.syncFromBackend([_playlist]); + PlaylistMockData.instance.addTrack( + playlistId: 'playlist-1', + track: _tracks.first, + ); + when( + () => datasource.updatePlaylist( + playlistId: 'playlist-1', + subtype: 'album', + releaseDate: any(named: 'releaseDate'), + ), + ).thenAnswer((_) async => _album); + when( + () => datasource.updatePlaylist( + playlistId: 'playlist-1', + subtype: 'playlist', + ), + ).thenAnswer((_) async => _playlist); + when( + () => datasource.fetchRelatedTracks('track-1', limit: 50), + ).thenAnswer((_) async => [_tracks.last]); + when( + () => datasource.fetchMyPlaylists(filter: 'created'), + ).thenAnswer((_) async => [_playlist]); + + final container = _container(datasource); + final notifier = container.read(playlistListProvider.notifier); + + await notifier.convertToAlbum('playlist-1'); + await notifier.convertToPlaylist('playlist-1'); + await notifier.convertToStation('playlist-1'); + + verify( + () => datasource.updatePlaylist( + playlistId: 'playlist-1', + subtype: 'album', + releaseDate: any(named: 'releaseDate'), + ), + ).called(1); + verify( + () => datasource.updatePlaylist( + playlistId: 'playlist-1', + subtype: 'playlist', + ), + ).called(1); + verify( + () => datasource.fetchRelatedTracks('track-1', limit: 50), + ).called(1); + + when( + () => datasource.updatePlaylist( + playlistId: 'broken', + subtype: 'playlist', + ), + ).thenThrow(_dioError('/playlists/broken')); + await notifier.convertToPlaylist('broken'); + }, + ); + + test( + 'createStation variants add local station playlists from playlist and core track entities', + () { + final container = _container(datasource); + final notifier = container.read(playlistListProvider.notifier); + + final fromPlaylistTrack = notifier.createStation(_tracks.first); + final fromCoreTrack = notifier.createStationFromTrack( + Track( + id: 'core-track-1', + userId: 'artist-1', + title: 'Core Track', + artist: 'Core Artist', + audioUrl: 'audio.mp3', + duration: const Duration(minutes: 2), + createdAt: DateTime(2024, 1, 1), + coverImage: 'core.jpg', + ), + ); + + expect(fromPlaylistTrack.type, PlaylistType.station); + expect(fromPlaylistTrack.seedArtistName, 'Artist'); + expect(fromCoreTrack.name, 'Core Artist Radio'); + expect(container.read(playlistListProvider).playlists, hasLength(2)); + }, + ); + }); + + group('PlaylistDetailNotifier generated-content behavior', () { + test('passes authenticated user identity to detail fetch', () async { + when( + () => datasource.fetchPlaylistDetail( + 'playlist-1', + currentUserId: any(named: 'currentUserId'), + currentUserName: any(named: 'currentUserName'), + ), + ).thenAnswer((_) async => _playlist); + when( + () => datasource.fetchPlaylistTracks('playlist-1'), + ).thenAnswer((_) async => _tracks); + + final container = _container( + datasource, + authState: const AuthAuthenticated( + UserEntity( + id: 'current-user', + email: 'me@example.com', + displayName: 'Current User', + isEmailVerified: true, + ), + ), + ); + await container.read(playlistDetailProvider.notifier).init('playlist-1'); + + verify( + () => datasource.fetchPlaylistDetail( + 'playlist-1', + currentUserId: 'current-user', + currentUserName: 'Current User', + ), + ).called(1); + }); + + test( + 'init fetches station tracks using seed artist and falls back to playlist tracks without seed', + () async { + when( + () => datasource.fetchPlaylistDetail( + 'station-1', + currentUserId: any(named: 'currentUserId'), + currentUserName: any(named: 'currentUserName'), + ), + ).thenAnswer((_) async => _station); + when( + () => datasource.fetchStationTracks('artist-1', limit: 50), + ).thenAnswer((_) async => [_tracks.first]); + final noSeed = PlaylistEntity( + id: 'station-2', + name: 'Seedless Station', + ownerName: 'Owner', + ownerId: 'owner-1', + isPublic: true, + type: PlaylistType.station, + trackCount: 1, + totalDuration: const Duration(minutes: 4), + createdAt: DateTime(2024, 1, 1), + ); + when( + () => datasource.fetchPlaylistDetail( + 'station-2', + currentUserId: any(named: 'currentUserId'), + currentUserName: any(named: 'currentUserName'), + ), + ).thenAnswer((_) async => noSeed); + when( + () => datasource.fetchPlaylistTracks('station-2'), + ).thenAnswer((_) async => [_tracks.last]); + + final container = _container(datasource); + await container.read(playlistDetailProvider.notifier).init('station-1'); + expect( + container.read(playlistDetailProvider).tracks.single.id, + 'track-1', + ); + + await container.read(playlistDetailProvider.notifier).init('station-2'); + expect( + container.read(playlistDetailProvider).tracks.single.id, + 'track-2', + ); + }, + ); + + test( + 'init falls back from empty radio and playlist tracks to alternate endpoints', + () async { + when( + () => datasource.fetchPlaylistDetail( + 'radio-1', + currentUserId: any(named: 'currentUserId'), + currentUserName: any(named: 'currentUserName'), + ), + ).thenAnswer((_) async => _trackRadio); + when( + () => datasource.fetchRadioTracks('radio-1'), + ).thenAnswer((_) async => []); + when( + () => datasource.fetchPlaylistTracks('radio-1'), + ).thenAnswer((_) async => [_tracks.first]); + + when( + () => datasource.fetchPlaylistDetail( + 'mix-1', + currentUserId: any(named: 'currentUserId'), + currentUserName: any(named: 'currentUserName'), + ), + ).thenAnswer((_) async => _mix); + when( + () => datasource.fetchPlaylistTracks('mix-1'), + ).thenAnswer((_) async => []); + when( + () => datasource.fetchMixTracks('mix-1'), + ).thenAnswer((_) async => [_tracks.last]); + + final container = _container(datasource); + await container.read(playlistDetailProvider.notifier).init('radio-1'); + expect( + container.read(playlistDetailProvider).tracks.single.id, + 'track-1', + ); + + await container.read(playlistDetailProvider.notifier).init('mix-1'); + expect( + container.read(playlistDetailProvider).tracks.single.id, + 'track-2', + ); + }, + ); + + test( + 'toggleLike calls unlike for liked playlists and keeps successful state', + () async { + when( + () => datasource.fetchPlaylistDetail( + 'playlist-1', + currentUserId: any(named: 'currentUserId'), + currentUserName: any(named: 'currentUserName'), + ), + ).thenAnswer((_) async => _playlist.copyWith(isLiked: true)); + when( + () => datasource.fetchPlaylistTracks('playlist-1'), + ).thenAnswer((_) async => _tracks); + when( + () => datasource.unlikePlaylist('playlist-1'), + ).thenAnswer((_) async {}); + + final container = _container(datasource); + await container + .read(playlistDetailProvider.notifier) + .init('playlist-1'); + await container.read(playlistDetailProvider.notifier).toggleLike(); + + expect( + container.read(playlistDetailProvider).playlist?.isLiked, + isFalse, + ); + verify(() => datasource.unlikePlaylist('playlist-1')).called(1); + }, + ); + + test('addSuggestion refreshes tracks and suggestions on success', () async { + final suggestion = _suggestionTrack(); + when( + () => datasource.fetchPlaylistDetail( + 'playlist-1', + currentUserId: any(named: 'currentUserId'), + currentUserName: any(named: 'currentUserName'), + ), + ).thenAnswer((_) async => _playlist); + when( + () => datasource.fetchPlaylistTracks('playlist-1'), + ).thenAnswer((_) async => [_tracks.first]); + when( + () => datasource.fetchRecommendedTracksExcluding( + excludeIds: any(named: 'excludeIds'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => [suggestion]); + when( + () => datasource.addTrackToPlaylist( + playlistId: 'playlist-1', + trackId: 'suggestion-1', + ), + ).thenAnswer((_) async {}); + + final container = _container(datasource); + await container.read(playlistDetailProvider.notifier).init('playlist-1'); + await container + .read(playlistDetailProvider.notifier) + .loadSuggestionsIfOwner(); + + when( + () => datasource.fetchPlaylistTracks('playlist-1'), + ).thenAnswer((_) async => [_tracks.first, suggestion]); + when( + () => datasource.fetchRecommendedTracksExcluding( + excludeIds: ['track-1', 'suggestion-1'], + limit: 5, + ), + ).thenAnswer((_) async => []); + + await container + .read(playlistDetailProvider.notifier) + .addSuggestion(suggestion); + + final state = container.read(playlistDetailProvider); + expect(state.tracks.map((t) => t.id), ['track-1', 'suggestion-1']); + expect(state.suggestions, isEmpty); + }); + + test( + 'addSuggestion restores suggestion and sets error when API rejects it', + () async { + final suggestion = _suggestionTrack(); + when( + () => datasource.fetchPlaylistDetail( + 'playlist-1', + currentUserId: any(named: 'currentUserId'), + currentUserName: any(named: 'currentUserName'), + ), + ).thenAnswer((_) async => _playlist); + when( + () => datasource.fetchPlaylistTracks('playlist-1'), + ).thenAnswer((_) async => [_tracks.first]); + when( + () => datasource.fetchRecommendedTracksExcluding( + excludeIds: any(named: 'excludeIds'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => [suggestion]); + when( + () => datasource.addTrackToPlaylist( + playlistId: 'playlist-1', + trackId: 'suggestion-1', + ), + ).thenThrow(_dioError('/playlists/playlist-1/tracks', 403)); + + final container = _container(datasource); + await container + .read(playlistDetailProvider.notifier) + .init('playlist-1'); + await container + .read(playlistDetailProvider.notifier) + .loadSuggestionsIfOwner(); + await container + .read(playlistDetailProvider.notifier) + .addSuggestion(suggestion); + + final state = container.read(playlistDetailProvider); + expect(state.suggestions.single.id, 'suggestion-1'); + expect(state.error, 'Could not add "Two". Try again.'); + }, + ); + + test( + 'refreshSuggestions and reload use existing state and current playlist id', + () async { + when( + () => datasource.fetchPlaylistDetail( + 'playlist-1', + currentUserId: any(named: 'currentUserId'), + currentUserName: any(named: 'currentUserName'), + ), + ).thenAnswer((_) async => _playlist); + when( + () => datasource.fetchPlaylistTracks('playlist-1'), + ).thenAnswer((_) async => [_tracks.first]); + when( + () => datasource.fetchRecommendedTracksExcluding( + excludeIds: ['track-1'], + limit: 5, + ), + ).thenAnswer((_) async => [_tracks.last]); + + final container = _container(datasource); + final notifier = container.read(playlistDetailProvider.notifier); + await notifier.init('playlist-1'); + await notifier.refreshSuggestions(); + + expect( + container.read(playlistDetailProvider).suggestions.single.id, + 'track-2', + ); + + notifier.reload(); + await Future.delayed(Duration.zero); + verify( + () => datasource.fetchPlaylistDetail( + 'playlist-1', + currentUserId: any(named: 'currentUserId'), + currentUserName: any(named: 'currentUserName'), + ), + ).called(2); + }, + ); + }); +} diff --git a/test/features/playlist/playlist_providers_behavior_test.dart b/test/features/playlist/playlist_providers_behavior_test.dart new file mode 100644 index 00000000..cc82e13f --- /dev/null +++ b/test/features/playlist/playlist_providers_behavior_test.dart @@ -0,0 +1,413 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/playlist/data/datasources/playlist_remote_datasource.dart'; +import 'package:rythmify/features/playlist/data/mock/playlist_mock_data.dart'; +import 'package:rythmify/features/playlist/domain/entities/playlist_entity.dart'; +import 'package:rythmify/features/playlist/domain/entities/playlist_track.dart'; +import 'package:rythmify/features/playlist/presentation/providers/playlist_provider.dart'; +import 'package:rythmify/features/authentication/presentation/providers/auth_provider.dart'; +import 'package:rythmify/features/authentication/presentation/providers/auth_state.dart'; + +class MockPlaylistRemoteDatasource extends Mock + implements PlaylistRemoteDatasource {} + +class TestAuthNotifier extends AuthNotifier { + @override + AuthState build() => const AuthUnauthenticated(); +} + +final _playlist = PlaylistEntity( + id: 'playlist-1', + name: 'Night Drive', + ownerName: 'Owner', + ownerId: 'user-1', + isPublic: true, + type: PlaylistType.playlist, + trackCount: 2, + totalDuration: const Duration(minutes: 6), + createdAt: DateTime(2024, 1, 1), + isLiked: false, +); + +final _privatePlaylist = _playlist.copyWith( + id: 'private-1', + name: 'Private Drafts', + isPublic: false, +); + +final _tracks = [ + PlaylistTrack( + id: 'track-1', + title: 'One', + artistName: 'Artist', + duration: const Duration(minutes: 3), + playCount: 10, + position: 1, + ), + PlaylistTrack( + id: 'track-2', + title: 'Two', + artistName: 'Artist', + duration: const Duration(minutes: 4), + playCount: 20, + position: 2, + ), +]; + +DioException _dioError(String path, [int statusCode = 500]) { + return DioException( + requestOptions: RequestOptions(path: path), + response: Response( + requestOptions: RequestOptions(path: path), + statusCode: statusCode, + ), + ); +} + +ProviderContainer _container(MockPlaylistRemoteDatasource datasource) { + final container = ProviderContainer( + overrides: [ + playlistDatasourceProvider.overrideWithValue(datasource), + authProvider.overrideWith(TestAuthNotifier.new), + ], + ); + addTearDown(container.dispose); + return container; +} + +void main() { + late MockPlaylistRemoteDatasource datasource; + + setUp(() { + datasource = MockPlaylistRemoteDatasource(); + PlaylistMockData.instance.syncFromBackend([]); + }); + + group('PlaylistListNotifier', () { + test( + 'starts loading then loads owned playlists into state and cache', + () async { + when( + () => datasource.fetchMyPlaylists(filter: 'created'), + ).thenAnswer((_) async => [_playlist, _privatePlaylist]); + + final container = _container(datasource); + expect(container.read(playlistListProvider).isLoading, isTrue); + + await container.read(playlistListProvider.notifier).loadPlaylists(); + + final state = container.read(playlistListProvider); + expect(state.isLoading, isFalse); + expect(state.error, isNull); + expect(state.playlists.map((p) => p.id), ['playlist-1', 'private-1']); + expect(PlaylistMockData.instance.getMyPlaylists().map((p) => p.id), [ + 'playlist-1', + 'private-1', + ]); + }, + ); + + test('falls back to cached playlists when refresh fails', () async { + PlaylistMockData.instance.syncFromBackend([_playlist]); + when( + () => datasource.fetchMyPlaylists(filter: 'created'), + ).thenThrow(_dioError('/playlists')); + + final container = _container(datasource); + await container.read(playlistListProvider.notifier).loadPlaylists(); + + final state = container.read(playlistListProvider); + expect(state.isLoading, isFalse); + expect(state.error, 'Could not refresh playlists'); + expect(state.playlists.single.id, 'playlist-1'); + }); + + test( + 'loads liked playlists and surfaces failures without stale data', + () async { + when( + () => datasource.fetchLikedPlaylists(), + ).thenAnswer((_) async => [_privatePlaylist]); + + final container = _container(datasource); + await container + .read(playlistListProvider.notifier) + .loadLikedPlaylists(); + + expect( + container.read(playlistListProvider).playlists.single.isPublic, + isFalse, + ); + + when( + () => datasource.fetchLikedPlaylists(), + ).thenThrow(_dioError('/me/liked-playlists')); + await container + .read(playlistListProvider.notifier) + .loadLikedPlaylists(); + + final failed = container.read(playlistListProvider); + expect(failed.playlists, isEmpty); + expect(failed.error, 'Could not load liked playlists'); + }, + ); + + test( + 'create, update, and delete call datasource then reload state', + () async { + when( + () => datasource.createPlaylist( + name: any(named: 'name'), + isPublic: any(named: 'isPublic'), + ), + ).thenAnswer((_) async => _privatePlaylist); + when( + () => datasource.fetchMyPlaylists(filter: 'created'), + ).thenAnswer((_) async => [_privatePlaylist]); + when( + () => datasource.updatePlaylist( + playlistId: any(named: 'playlistId'), + name: any(named: 'name'), + isPublic: any(named: 'isPublic'), + description: any(named: 'description'), + ), + ).thenAnswer((_) async => _privatePlaylist.copyWith(name: 'Updated')); + when( + () => datasource.deletePlaylist('private-1'), + ).thenAnswer((_) async {}); + + final container = _container(datasource); + final created = await container + .read(playlistListProvider.notifier) + .createPlaylist(name: 'Private Drafts', isPublic: false); + await container + .read(playlistListProvider.notifier) + .updatePlaylist( + playlistId: 'private-1', + name: 'Updated', + isPublic: false, + description: 'desc', + ); + await container + .read(playlistListProvider.notifier) + .deletePlaylist('private-1'); + + expect(created?.id, 'private-1'); + verify( + () => datasource.createPlaylist( + name: 'Private Drafts', + isPublic: false, + ), + ).called(1); + verify( + () => datasource.updatePlaylist( + playlistId: 'private-1', + name: 'Updated', + isPublic: false, + description: 'desc', + ), + ).called(1); + verify(() => datasource.deletePlaylist('private-1')).called(1); + }, + ); + + test( + 'create returns null when the API rejects unauthorized access', + () async { + when( + () => datasource.createPlaylist( + name: any(named: 'name'), + isPublic: any(named: 'isPublic'), + ), + ).thenThrow(_dioError('/playlists', 401)); + + final container = _container(datasource); + final created = await container + .read(playlistListProvider.notifier) + .createPlaylist(name: 'Nope', isPublic: true); + + expect(created, isNull); + }, + ); + }); + + group('PlaylistDetailNotifier', () { + test( + 'init loads playlist detail and tracks without auto-loading suggestions', + () async { + when( + () => datasource.fetchPlaylistDetail( + 'playlist-1', + currentUserId: any(named: 'currentUserId'), + currentUserName: any(named: 'currentUserName'), + ), + ).thenAnswer((_) async => _playlist); + when( + () => datasource.fetchPlaylistTracks('playlist-1'), + ).thenAnswer((_) async => _tracks); + + final container = _container(datasource); + await container + .read(playlistDetailProvider.notifier) + .init('playlist-1'); + + final state = container.read(playlistDetailProvider); + expect(state.isLoading, isFalse); + expect(state.playlist?.id, 'playlist-1'); + expect(state.tracks.map((t) => t.id), ['track-1', 'track-2']); + expect(state.suggestions, isEmpty); + expect(state.totalDuration, const Duration(minutes: 7)); + }, + ); + + test('loads owner suggestions excluding existing tracks', () async { + final suggestion = _tracks.first.copyWith(position: 0); + when( + () => datasource.fetchPlaylistDetail( + 'playlist-1', + currentUserId: any(named: 'currentUserId'), + currentUserName: any(named: 'currentUserName'), + ), + ).thenAnswer((_) async => _playlist); + when( + () => datasource.fetchPlaylistTracks('playlist-1'), + ).thenAnswer((_) async => [_tracks.first]); + when( + () => datasource.fetchRecommendedTracksExcluding( + excludeIds: any(named: 'excludeIds'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => [suggestion.copyWith(position: 0)]); + + final container = _container(datasource); + await container.read(playlistDetailProvider.notifier).init('playlist-1'); + await container + .read(playlistDetailProvider.notifier) + .loadSuggestionsIfOwner(); + + final state = container.read(playlistDetailProvider); + expect(state.isSuggestionsLoading, isFalse); + expect(state.suggestions.single.id, 'track-1'); + verify( + () => datasource.fetchRecommendedTracksExcluding( + excludeIds: ['track-1'], + limit: 5, + ), + ).called(1); + }); + + test( + 'toggleLike optimistically updates and rolls back on failure', + () async { + when( + () => datasource.fetchPlaylistDetail( + 'playlist-1', + currentUserId: any(named: 'currentUserId'), + currentUserName: any(named: 'currentUserName'), + ), + ).thenAnswer((_) async => _playlist); + when( + () => datasource.fetchPlaylistTracks('playlist-1'), + ).thenAnswer((_) async => _tracks); + when( + () => datasource.likePlaylist('playlist-1'), + ).thenThrow(_dioError('/playlists/playlist-1/like')); + + final container = _container(datasource); + await container + .read(playlistDetailProvider.notifier) + .init('playlist-1'); + await container.read(playlistDetailProvider.notifier).toggleLike(); + + expect( + container.read(playlistDetailProvider).playlist?.isLiked, + isFalse, + ); + verify(() => datasource.likePlaylist('playlist-1')).called(1); + }, + ); + + test( + 'removeTrack performs optimistic update and restores cache on failure', + () async { + when( + () => datasource.fetchPlaylistDetail( + 'playlist-1', + currentUserId: any(named: 'currentUserId'), + currentUserName: any(named: 'currentUserName'), + ), + ).thenAnswer((_) async => _playlist); + when( + () => datasource.fetchPlaylistTracks('playlist-1'), + ).thenAnswer((_) async => _tracks); + when( + () => datasource.removeTrackFromPlaylist( + playlistId: 'playlist-1', + trackId: 'track-1', + ), + ).thenThrow(_dioError('/playlists/playlist-1/tracks/track-1')); + + final container = _container(datasource); + await container + .read(playlistDetailProvider.notifier) + .init('playlist-1'); + await container + .read(playlistDetailProvider.notifier) + .removeTrack('track-1'); + + expect(container.read(playlistDetailProvider).tracks.map((t) => t.id), [ + 'track-1', + 'track-2', + ]); + }, + ); + + test('uses cached detail when API fails and cache exists', () async { + PlaylistMockData.instance.syncFromBackend([_playlist]); + PlaylistMockData.instance.clearTracks('playlist-1'); + PlaylistMockData.instance.addTrack( + playlistId: 'playlist-1', + track: _tracks.first, + ); + when( + () => datasource.fetchPlaylistDetail( + 'playlist-1', + currentUserId: any(named: 'currentUserId'), + currentUserName: any(named: 'currentUserName'), + ), + ).thenThrow(_dioError('/playlists/playlist-1')); + + final container = _container(datasource); + await container.read(playlistDetailProvider.notifier).init('playlist-1'); + + final state = container.read(playlistDetailProvider); + expect(state.isLoading, isFalse); + expect(state.error, 'Showing cached data'); + expect(state.playlist?.id, 'playlist-1'); + expect(state.tracks.single.id, 'track-1'); + }); + + test( + 'reports error when invalid playlist id has no cached fallback', + () async { + when( + () => datasource.fetchPlaylistDetail( + 'missing', + currentUserId: any(named: 'currentUserId'), + currentUserName: any(named: 'currentUserName'), + ), + ).thenThrow(_dioError('/playlists/missing', 404)); + + final container = _container(datasource); + await container.read(playlistDetailProvider.notifier).init('missing'); + + final state = container.read(playlistDetailProvider); + expect(state.isLoading, isFalse); + expect(state.playlist, isNull); + expect(state.error, 'Could not load playlist'); + }, + ); + }); +} diff --git a/test/features/playlist/playlist_remote_datasource_discovery_test.dart b/test/features/playlist/playlist_remote_datasource_discovery_test.dart new file mode 100644 index 00000000..0edcaa11 --- /dev/null +++ b/test/features/playlist/playlist_remote_datasource_discovery_test.dart @@ -0,0 +1,1090 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/playlist/data/datasources/playlist_remote_datasource.dart'; +import 'package:rythmify/features/playlist/data/local/local_saved_store.dart'; +import 'package:rythmify/features/playlist/domain/entities/playlist_entity.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MockDio extends Mock implements Dio {} + +Response> _response( + String path, + Map data, +) { + return Response>( + requestOptions: RequestOptions(path: path), + statusCode: 200, + data: data, + ); +} + +DioException _dioError(String path, [int statusCode = 500]) { + return DioException( + requestOptions: RequestOptions(path: path), + response: Response( + requestOptions: RequestOptions(path: path), + statusCode: statusCode, + data: { + 'error': {'message': 'failed'}, + }, + ), + ); +} + +Map _playlistJson({ + String id = 'playlist-1', + String name = 'Playlist', + String subtype = 'playlist', + String? ownerId = 'owner-1', + bool isLiked = false, +}) { + return { + 'playlist_id': id, + 'id': id, + 'name': name, + 'owner_user_id': ownerId, + 'display_name': 'Owner', + 'is_public': true, + 'type': 'regular', + 'subtype': subtype, + 'track_count': 2, + 'duration': 120, + 'created_at': '2024-01-01T00:00:00Z', + 'is_liked_by_me': isLiked, + }; +} + +Map _trackJson( + String id, { + String? title, + String? artistName, + int duration = 90, + int playCount = 10, + bool liked = false, +}) { + return { + 'id': id, + 'track_id': id, + 'title': title ?? 'Track $id', + 'artist_name': artistName ?? 'Artist $id', + 'duration': duration, + 'play_count': playCount, + 'cover_image': 'cover-$id.jpg', + 'is_liked_by_me': liked, + 'stream_url': 'stream-$id', + 'audio_url': 'audio-$id', + }; +} + +void main() { + late MockDio dio; + late PlaylistRemoteDatasource datasource; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + dio = MockDio(); + datasource = PlaylistRemoteDatasource(dio); + }); + + group('liked and user playlist responses', () { + test( + 'fetchLikedPlaylists enriches generated mix and radio from local cache', + () async { + await LocalSavedStore.instance.saveMix( + SavedMix( + mixId: 'mix-1', + title: 'Cached Mix', + ownerName: 'Rythmify', + coverUrl: 'cached-mix.jpg', + trackCount: 24, + savedAt: DateTime.utc(2024, 1, 1), + ), + ); + await LocalSavedStore.instance.saveTrackRadio( + SavedTrackRadio( + trackId: 'seed-1', + playlistId: 'radio-playlist-1', + title: 'Cached Radio', + coverUrl: 'cached-radio.jpg', + trackCount: 18, + savedAt: DateTime.utc(2024, 1, 2), + ), + ); + when( + () => dio.get>( + '/me/liked-playlists', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( + (_) async => _response('/me/liked-playlists', { + 'data': { + 'items': [ + { + 'id': 'mix-1', + 'subtype': 'auto_generated', + 'name': 'Backend Mix', + 'liked_at': '2024-01-03T00:00:00Z', + }, + { + 'playlist_id': 'radio-playlist-1', + 'subtype': 'playlist', + 'title': 'Backend Radio', + 'liked_at': '2024-01-04T00:00:00Z', + }, + ], + }, + }), + ); + + final result = await datasource.fetchLikedPlaylists( + limit: 10, + offset: 5, + ); + + expect(result.map((p) => p.id), ['mix-1', 'radio-playlist-1']); + expect(result.first.isGeneratedMix, isTrue); + expect(result.first.coverUrl, 'cached-mix.jpg'); + expect(result.first.trackCount, 24); + expect(result.last.isTrackRadio, isTrue); + expect(result.last.coverUrl, 'cached-radio.jpg'); + verify( + () => dio.get>( + '/me/liked-playlists', + queryParameters: {'limit': 10, 'offset': 5}, + ), + ).called(1); + }, + ); + + test('fetchLikedPlaylists rethrows unauthorized API failures', () async { + when( + () => dio.get>( + '/me/liked-playlists', + queryParameters: any(named: 'queryParameters'), + ), + ).thenThrow(_dioError('/me/liked-playlists', 401)); + + expect( + () => datasource.fetchLikedPlaylists(), + throwsA(isA()), + ); + }); + + test( + 'fetchUserPlaylists uses mine query for me and user endpoint otherwise', + () async { + when( + () => dio.get>( + any(), + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer((invocation) async { + final path = invocation.positionalArguments.single as String; + return _response(path, { + 'data': { + 'items': [ + _playlistJson(id: path.contains('/users/') ? 'other' : 'me'), + ], + }, + }); + }); + + final mine = await datasource.fetchUserPlaylists( + userId: 'me', + limit: 7, + ); + final other = await datasource.fetchUserPlaylists(userId: 'user-2'); + + expect(mine.single.id, 'me'); + expect(other.single.id, 'other'); + verify( + () => dio.get>( + '/playlists', + queryParameters: {'limit': 7, 'mine': true, 'filter': 'created'}, + ), + ).called(1); + verify( + () => dio.get>( + '/users/user-2/playlists', + queryParameters: {'limit': 50}, + ), + ).called(1); + }, + ); + }); + + group('playlist detail and metadata operations', () { + test( + 'fetchPlaylistDetail resolves current user owner without extra user lookup', + () async { + when( + () => dio.get>( + '/playlists/playlist-1', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( + (_) async => _response('/playlists/playlist-1', { + 'data': _playlistJson(ownerId: 'current-user'), + }), + ); + + final playlist = await datasource.fetchPlaylistDetail( + 'playlist-1', + currentUserId: 'current-user', + currentUserName: 'Current User', + ); + + expect(playlist.ownerName, 'Current User'); + verifyNever(() => dio.get>('/users/current-user')); + }, + ); + + test( + 'fetchPlaylistDetail marks locally saved track radios and skips owner lookup', + () async { + await LocalSavedStore.instance.saveTrackRadio( + SavedTrackRadio( + trackId: 'seed-1', + playlistId: 'radio-playlist-1', + title: 'Saved Radio', + coverUrl: null, + trackCount: 10, + savedAt: DateTime.utc(2024, 1, 1), + ), + ); + when( + () => dio.get>( + '/playlists/radio-playlist-1', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( + (_) async => _response('/playlists/radio-playlist-1', { + 'data': _playlistJson(id: 'radio-playlist-1', ownerId: 'owner-2'), + }), + ); + + final playlist = await datasource.fetchPlaylistDetail( + 'radio-playlist-1', + ); + + expect(playlist.isTrackRadio, isTrue); + expect(playlist.ownerName, ''); + verifyNever(() => dio.get>('/users/owner-2')); + }, + ); + + test( + 'fetchPlaylistDetail resolves non-radio owner display name from user endpoint', + () async { + when( + () => dio.get>( + '/playlists/playlist-2', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( + (_) async => _response('/playlists/playlist-2', { + 'data': _playlistJson(id: 'playlist-2', ownerId: 'owner-2'), + }), + ); + when(() => dio.get>('/users/owner-2')).thenAnswer( + (_) async => _response('/users/owner-2', { + 'data': {'username': 'owner-two'}, + }), + ); + + final playlist = await datasource.fetchPlaylistDetail('playlist-2'); + + expect(playlist.isTrackRadio, isFalse); + expect(playlist.ownerName, 'owner-two'); + }, + ); + + test( + 'fetchTrackById returns the raw track payload and rethrows failures', + () async { + when(() => dio.get>('/tracks/track-1')).thenAnswer( + (_) async => + _response('/tracks/track-1', {'data': _trackJson('track-1')}), + ); + expect( + await datasource.fetchTrackById('track-1'), + containsPair('id', 'track-1'), + ); + + when( + () => dio.get>('/tracks/missing'), + ).thenThrow(_dioError('/tracks/missing', 404)); + expect( + () => datasource.fetchTrackById('missing'), + throwsA(isA()), + ); + }, + ); + + test( + 'updatePlaylist sends form fields for metadata and remove-cover updates', + () async { + when( + () => dio.patch>( + '/playlists/playlist-1', + data: any(named: 'data'), + ), + ).thenAnswer( + (_) async => _response('/playlists/playlist-1', { + 'data': _playlistJson( + id: 'playlist-1', + name: 'Updated', + subtype: 'album', + ), + }), + ); + + final result = await datasource.updatePlaylist( + playlistId: 'playlist-1', + name: 'Updated', + isPublic: false, + description: 'desc', + removeCover: true, + subtype: 'album', + releaseDate: '2026-01-01', + genreId: 'genre-1', + ); + + final formData = + verify( + () => dio.patch>( + '/playlists/playlist-1', + data: captureAny(named: 'data'), + ), + ).captured.single + as FormData; + final fields = Map.fromEntries(formData.fields); + expect(result.name, 'Updated'); + expect(result.type, PlaylistType.album); + expect(fields['name'], 'Updated'); + expect(fields['is_public'], 'false'); + expect(fields['remove_cover_image'], 'true'); + expect(fields['release_date'], '2026-01-01'); + expect(fields['genre_id'], 'genre-1'); + }, + ); + + test('updatePlaylist rethrows validation errors from the server', () async { + when( + () => dio.patch>( + '/playlists/bad', + data: any(named: 'data'), + ), + ).thenThrow(_dioError('/playlists/bad', 422)); + + expect( + () => datasource.updatePlaylist(playlistId: 'bad', name: ''), + throwsA(isA()), + ); + }); + }); + + group('track, station, mix, and recommendation fetching', () { + test( + 'fetchRadioTracks parses tracks and returns empty on Dio failure', + () async { + when( + () => dio.get>( + '/playlists/radio-1/radio-tracks', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( + (_) async => _response('/playlists/radio-1/radio-tracks', { + 'data': { + 'tracks': [ + _trackJson('track-1', liked: true), + {'title': 'bad'}, + ], + }, + }), + ); + final result = await datasource.fetchRadioTracks( + 'radio-1', + limit: 3, + offset: 6, + ); + expect(result, hasLength(1)); + expect(result.single.id, 'track-1'); + expect(result.single.isLiked, isTrue); + verify( + () => dio.get>( + '/playlists/radio-1/radio-tracks', + queryParameters: {'limit': 3, 'offset': 6}, + ), + ).called(1); + + when( + () => dio.get>( + '/playlists/radio-fail/radio-tracks', + queryParameters: any(named: 'queryParameters'), + ), + ).thenThrow(_dioError('/playlists/radio-fail/radio-tracks')); + expect(await datasource.fetchRadioTracks('radio-fail'), isEmpty); + }, + ); + + test( + 'fetchStations reads discovery stations, limits results, and rethrows malformed responses', + () async { + when(() => dio.get>('/home')).thenAnswer( + (_) async => _response('/home', { + 'data': { + 'discover_with_stations': [ + { + 'id': 'station-1', + 'name': 'One Radio', + 'seed_artist': {'user_id': 'artist-1', 'display_name': 'One'}, + }, + { + 'id': 'station-2', + 'name': 'Two Radio', + 'seed_artist': {'display_name': 'Two'}, + }, + ], + }, + }), + ); + expect( + (await datasource.fetchStations(limit: 1)).single.id, + 'station-1', + ); + + when( + () => dio.get>('/home'), + ).thenAnswer((_) async => _response('/home', {'data': {}})); + expect(() => datasource.fetchStations(), throwsA(isA())); + + when( + () => dio.get>('/home'), + ).thenThrow(_dioError('/home')); + expect(() => datasource.fetchStations(), throwsA(isA())); + }, + ); + + test( + 'fetchSavedStations parses defaults and returns empty on Dio failure', + () async { + when( + () => dio.get>( + '/users/me/stations', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( + (_) async => _response('/users/me/stations', { + 'data': [ + { + 'artist_id': 'artist-1', + 'artist_name': null, + 'profile_picture': 'artist.jpg', + 'track_count': 4, + 'saved_at': '2024-02-01T00:00:00Z', + }, + ], + }), + ); + + final stations = await datasource.fetchSavedStations( + limit: 2, + offset: 8, + ); + expect(stations.single.artistName, 'Unknown Artist'); + expect(stations.single.stationName, 'Unknown Radio'); + verify( + () => dio.get>( + '/users/me/stations', + queryParameters: {'limit': 2, 'offset': 8}, + ), + ).called(1); + + when( + () => dio.get>( + '/users/me/stations', + queryParameters: any(named: 'queryParameters'), + ), + ).thenThrow(_dioError('/users/me/stations')); + expect(await datasource.fetchSavedStations(), isEmpty); + }, + ); + + test( + 'fetchStationTracks handles list, map, unexpected, and network shapes', + () async { + when( + () => dio.get>( + '/home/stations/artist-list/tracks', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( + (_) async => _response('/home/stations/artist-list/tracks', { + 'data': [_trackJson('track-1')], + }), + ); + expect( + (await datasource.fetchStationTracks('artist-list')).single.id, + 'track-1', + ); + + when( + () => dio.get>( + '/home/stations/artist-map/tracks', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( + (_) async => _response('/home/stations/artist-map/tracks', { + 'data': { + 'tracks': [_trackJson('track-2')], + }, + }), + ); + expect( + (await datasource.fetchStationTracks('artist-map')).single.id, + 'track-2', + ); + + when( + () => dio.get>( + '/home/stations/artist-empty/tracks', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( + (_) async => + _response('/home/stations/artist-empty/tracks', {'data': 'bad'}), + ); + expect(await datasource.fetchStationTracks('artist-empty'), isEmpty); + + when( + () => dio.get>( + '/home/stations/artist-fail/tracks', + queryParameters: any(named: 'queryParameters'), + ), + ).thenThrow(_dioError('/home/stations/artist-fail/tracks')); + expect(await datasource.fetchStationTracks('artist-fail'), isEmpty); + }, + ); + + test( + 'fetchRelatedTracks parses tracks and swallows Dio failures', + () async { + when( + () => dio.get>( + '/tracks/track-1/related', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( + (_) async => _response('/tracks/track-1/related', { + 'data': { + 'tracks': [_trackJson('related-1')], + }, + }), + ); + expect( + (await datasource.fetchRelatedTracks('track-1')).single.id, + 'related-1', + ); + + when( + () => dio.get>( + '/tracks/fail/related', + queryParameters: any(named: 'queryParameters'), + ), + ).thenThrow(_dioError('/tracks/fail/related')); + expect(await datasource.fetchRelatedTracks('fail'), isEmpty); + }, + ); + + test( + 'fetchMixTracks supports map, list, malformed, and network responses', + () async { + when(() => dio.get>('/home/mixes/map')).thenAnswer( + (_) async => _response('/home/mixes/map', { + 'data': { + 'tracks': [ + _trackJson('mix-1'), + {'id': ''}, + 'bad', + ], + }, + }), + ); + expect((await datasource.fetchMixTracks('map')).single.id, 'mix-1'); + + when( + () => dio.get>('/home/mixes/list'), + ).thenAnswer( + (_) async => _response('/home/mixes/list', { + 'data': [_trackJson('mix-2')], + }), + ); + expect((await datasource.fetchMixTracks('list')).single.id, 'mix-2'); + + when( + () => dio.get>('/home/mixes/bad'), + ).thenAnswer((_) async => _response('/home/mixes/bad', {'data': 42})); + expect(await datasource.fetchMixTracks('bad'), isEmpty); + + when( + () => dio.get>('/home/mixes/fail'), + ).thenThrow(_dioError('/home/mixes/fail')); + expect(await datasource.fetchMixTracks('fail'), isEmpty); + }, + ); + + test( + 'daily and weekly mix endpoints support map, list, malformed, and failure responses', + () async { + when( + () => dio.get>('/home/made-for-you/daily'), + ).thenAnswer( + (_) async => _response('/home/made-for-you/daily', { + 'data': { + 'tracks': [_trackJson('daily-map')], + }, + }), + ); + expect((await datasource.fetchDailyMixTracks()).single.id, 'daily-map'); + + when( + () => dio.get>('/home/made-for-you/daily'), + ).thenAnswer( + (_) async => _response('/home/made-for-you/daily', { + 'data': [_trackJson('daily-list')], + }), + ); + expect( + (await datasource.fetchDailyMixTracks()).single.id, + 'daily-list', + ); + + when( + () => dio.get>('/home/made-for-you/daily'), + ).thenAnswer( + (_) async => _response('/home/made-for-you/daily', {'data': null}), + ); + expect(await datasource.fetchDailyMixTracks(), isEmpty); + + when( + () => dio.get>('/home/made-for-you/daily'), + ).thenThrow(_dioError('/home/made-for-you/daily')); + expect(await datasource.fetchDailyMixTracks(), isEmpty); + + when( + () => dio.get>('/home/made-for-you/weekly'), + ).thenAnswer( + (_) async => _response('/home/made-for-you/weekly', { + 'data': { + 'tracks': [_trackJson('weekly-map')], + }, + }), + ); + expect( + (await datasource.fetchWeeklyMixTracks()).single.id, + 'weekly-map', + ); + + when( + () => dio.get>('/home/made-for-you/weekly'), + ).thenAnswer( + (_) async => _response('/home/made-for-you/weekly', { + 'data': [_trackJson('weekly-list')], + }), + ); + expect( + (await datasource.fetchWeeklyMixTracks()).single.id, + 'weekly-list', + ); + + when( + () => dio.get>('/home/made-for-you/weekly'), + ).thenAnswer( + (_) async => _response('/home/made-for-you/weekly', {'data': 'bad'}), + ); + expect(await datasource.fetchWeeklyMixTracks(), isEmpty); + + when( + () => dio.get>('/home/made-for-you/weekly'), + ).thenThrow(_dioError('/home/made-for-you/weekly')); + expect(await datasource.fetchWeeklyMixTracks(), isEmpty); + }, + ); + + test( + 'fetchRecommendedTracks reads genre IDs, skips invalid tracks, dedupes, and excludes', + () async { + when(() => dio.get>('/home')).thenAnswer( + (_) async => _response('/home', { + 'data': { + 'trending_by_genre': { + 'genres': [ + {'genre_id': 'genre-1'}, + {'genre_id': 'genre-2'}, + {'genre_id': ''}, + ], + }, + }, + }), + ); + when( + () => dio.get>( + '/genres/genre-1/tracks', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( + (_) async => _response('/genres/genre-1/tracks', { + 'data': [ + _trackJson('rec-1'), + _trackJson('rec-2'), + _trackJson('rec-1'), + _trackJson('c0000-seed'), + {'title': 'missing id'}, + ], + }), + ); + when( + () => dio.get>( + '/genres/genre-2/tracks', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( + (_) async => _response('/genres/genre-2/tracks', { + 'data': { + 'tracks': [_trackJson('rec-3'), 'malformed'], + }, + }), + ); + + final result = await datasource.fetchRecommendedTracks(limit: 10); + expect(result.map((t) => t.id).toSet(), {'rec-1', 'rec-2', 'rec-3'}); + + when(() => dio.get>('/home')).thenAnswer( + (_) async => _response('/home', { + 'data': { + 'trending_by_genre': { + 'genres': [ + {'genre_id': 'genre-1'}, + ], + }, + }, + }), + ); + when( + () => dio.get>( + '/genres/genre-1/tracks', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( + (_) async => _response('/genres/genre-1/tracks', { + 'data': [_trackJson('rec-1'), _trackJson('rec-2')], + }), + ); + final excluding = await datasource.fetchRecommendedTracksExcluding( + excludeIds: ['rec-1'], + limit: 5, + ); + expect(excluding.map((t) => t.id), ['rec-2']); + }, + ); + + test( + 'fetchRecommendedTracks returns empty for missing home genres, genre failures, and malformed payloads', + () async { + when( + () => dio.get>('/home'), + ).thenAnswer((_) async => _response('/home', {'data': {}})); + expect(await datasource.fetchRecommendedTracks(), isEmpty); + + when(() => dio.get>('/home')).thenAnswer( + (_) async => _response('/home', { + 'data': { + 'trending_by_genre': { + 'genres': [ + {'genre_id': 'genre-fail'}, + ], + }, + }, + }), + ); + when( + () => dio.get>( + '/genres/genre-fail/tracks', + queryParameters: any(named: 'queryParameters'), + ), + ).thenThrow(_dioError('/genres/genre-fail/tracks')); + expect(await datasource.fetchRecommendedTracks(), isEmpty); + + when( + () => dio.get>('/home'), + ).thenThrow(_dioError('/home')); + expect(await datasource.fetchRecommendedTracks(), isEmpty); + }, + ); + }); + + group('engagement endpoints', () { + test( + 'like, unlike, repost, and remove-repost call the correct endpoints', + () async { + when(() => dio.post('/playlists/playlist-1/like')).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/playlists/playlist-1/like'), + ), + ); + when( + () => dio.delete('/playlists/playlist-1/like'), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/playlists/playlist-1/like'), + ), + ); + when( + () => dio.post('/playlists/playlist-1/repost'), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions( + path: '/playlists/playlist-1/repost', + ), + ), + ); + when( + () => dio.delete('/playlists/playlist-1/repost'), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions( + path: '/playlists/playlist-1/repost', + ), + ), + ); + + await datasource.likePlaylist('playlist-1'); + await datasource.unlikePlaylist('playlist-1'); + await datasource.repostPlaylist('playlist-1'); + await datasource.removeRepost('playlist-1'); + + verify(() => dio.post('/playlists/playlist-1/like')).called(1); + verify( + () => dio.delete('/playlists/playlist-1/like'), + ).called(1); + verify( + () => dio.post('/playlists/playlist-1/repost'), + ).called(1); + verify( + () => dio.delete('/playlists/playlist-1/repost'), + ).called(1); + }, + ); + + test( + 'mix, station, and track-radio engagement endpoints parse responses and rethrow failures', + () async { + when(() => dio.post('/home/mixes/mix-1/like')).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/home/mixes/mix-1/like'), + ), + ); + when(() => dio.delete('/home/mixes/mix-1/like')).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/home/mixes/mix-1/like'), + ), + ); + when(() => dio.post('/stations/artist-1/like')).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/stations/artist-1/like'), + ), + ); + when(() => dio.delete('/stations/artist-1/like')).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/stations/artist-1/like'), + ), + ); + when( + () => dio.post>('/tracks/track-1/like-radio'), + ).thenAnswer( + (_) async => _response('/tracks/track-1/like-radio', { + 'data': {'playlist_id': 'radio-playlist-1'}, + }), + ); + when( + () => dio.delete('/tracks/track-1/like-radio'), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/tracks/track-1/like-radio'), + ), + ); + + await datasource.likeMix('mix-1'); + await datasource.unlikeMix('mix-1'); + await datasource.likeStation('artist-1'); + await datasource.unlikeStation('artist-1'); + expect(await datasource.likeTrackRadio('track-1'), 'radio-playlist-1'); + await datasource.unlikeTrackRadio('track-1'); + + when( + () => dio.post('/home/mixes/fail/like'), + ).thenThrow(_dioError('/home/mixes/fail/like')); + expect(() => datasource.likeMix('fail'), throwsA(isA())); + }, + ); + + test( + 'critical write endpoints rethrow server, network, and authorization failures', + () async { + when( + () => dio.post>( + '/playlists', + data: any(named: 'data'), + ), + ).thenThrow(_dioError('/playlists', 422)); + expect( + () => datasource.createPlaylist(name: '', isPublic: true), + throwsA(isA()), + ); + + when( + () => dio.delete('/playlists/playlist-1'), + ).thenThrow(_dioError('/playlists/playlist-1', 403)); + expect( + () => datasource.deletePlaylist('playlist-1'), + throwsA(isA()), + ); + + when( + () => dio.post( + '/playlists/playlist-1/tracks', + data: any(named: 'data'), + ), + ).thenThrow(_dioError('/playlists/playlist-1/tracks', 500)); + expect( + () => datasource.addTrackToPlaylist( + playlistId: 'playlist-1', + trackId: 'track-1', + ), + throwsA(isA()), + ); + + when( + () => dio.delete('/playlists/playlist-1/tracks/track-1'), + ).thenThrow(_dioError('/playlists/playlist-1/tracks/track-1', 404)); + expect( + () => datasource.removeTrackFromPlaylist( + playlistId: 'playlist-1', + trackId: 'track-1', + ), + throwsA(isA()), + ); + + when( + () => dio.patch( + '/playlists/playlist-1/tracks/reorder', + data: any(named: 'data'), + ), + ).thenThrow(_dioError('/playlists/playlist-1/tracks/reorder', 409)); + expect( + () => datasource.reorderPlaylistTracks( + playlistId: 'playlist-1', + orderedTrackIds: ['track-1'], + ), + throwsA(isA()), + ); + + when( + () => dio.post('/playlists/fail/like'), + ).thenThrow(_dioError('/playlists/fail/like', 401)); + when( + () => dio.delete('/playlists/fail/like'), + ).thenThrow(_dioError('/playlists/fail/like', 401)); + when( + () => dio.delete('/home/mixes/fail/like'), + ).thenThrow(_dioError('/home/mixes/fail/like', 500)); + when( + () => dio.post('/stations/fail/like'), + ).thenThrow(_dioError('/stations/fail/like', 500)); + when( + () => dio.delete('/stations/fail/like'), + ).thenThrow(_dioError('/stations/fail/like', 500)); + when( + () => dio.post>('/tracks/fail/like-radio'), + ).thenThrow(_dioError('/tracks/fail/like-radio', 500)); + when( + () => dio.delete('/tracks/fail/like-radio'), + ).thenThrow(_dioError('/tracks/fail/like-radio', 500)); + when( + () => dio.post('/playlists/fail/repost'), + ).thenThrow(_dioError('/playlists/fail/repost', 500)); + when( + () => dio.delete('/playlists/fail/repost'), + ).thenThrow(_dioError('/playlists/fail/repost', 500)); + + expect( + () => datasource.likePlaylist('fail'), + throwsA(isA()), + ); + expect( + () => datasource.unlikePlaylist('fail'), + throwsA(isA()), + ); + expect( + () => datasource.unlikeMix('fail'), + throwsA(isA()), + ); + expect( + () => datasource.likeStation('fail'), + throwsA(isA()), + ); + expect( + () => datasource.unlikeStation('fail'), + throwsA(isA()), + ); + expect( + () => datasource.likeTrackRadio('fail'), + throwsA(isA()), + ); + expect( + () => datasource.unlikeTrackRadio('fail'), + throwsA(isA()), + ); + expect( + () => datasource.repostPlaylist('fail'), + throwsA(isA()), + ); + expect( + () => datasource.removeRepost('fail'), + throwsA(isA()), + ); + }, + ); + }); + + group('core failure paths', () { + test( + 'fetchUserPlaylists and paginated tracks rethrow protected API failures', + () async { + when( + () => dio.get>( + '/users/private/playlists', + queryParameters: any(named: 'queryParameters'), + ), + ).thenThrow(_dioError('/users/private/playlists', 403)); + expect( + () => datasource.fetchUserPlaylists(userId: 'private'), + throwsA(isA()), + ); + + when( + () => dio.get>( + '/playlists/playlist-1/tracks', + queryParameters: any(named: 'queryParameters'), + ), + ).thenThrow(_dioError('/playlists/playlist-1/tracks', 500)); + expect( + () => datasource.fetchPlaylistTracks('playlist-1'), + throwsA(isA()), + ); + }, + ); + }); +} diff --git a/test/features/playlist/playlist_test.dart b/test/features/playlist/playlist_test.dart index 4be4904d..421bc71b 100644 --- a/test/features/playlist/playlist_test.dart +++ b/test/features/playlist/playlist_test.dart @@ -1,3 +1,158 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/core/domain/entities/track.dart'; +import 'package:rythmify/features/playlist/domain/entities/playlist_entity.dart'; +import 'package:rythmify/features/playlist/domain/entities/playlist_track.dart'; + +void main() { + group('Playlist domain entities', () { + test('PlaylistTrack.fromTrack keeps player-facing track identity', () { + final track = Track( + id: 'track-001', + userId: 'user-001', + title: 'Neon Lights', + artist: 'DJ Sample', + audioUrl: 'https://example.com/audio.mp3', + streamUrl: 'https://example.com/stream', + duration: const Duration(minutes: 3, seconds: 31), + createdAt: DateTime(2024, 1, 1), + playCount: 99000, + isLiked: true, + coverImage: 'https://example.com/cover.jpg', + ); + + final playlistTrack = PlaylistTrack.fromTrack(track, position: 4); + + expect(playlistTrack.id, 'track-001'); + expect(playlistTrack.trackId, 'track-001'); + expect(playlistTrack.title, 'Neon Lights'); + expect(playlistTrack.artistName, 'DJ Sample'); + expect(playlistTrack.position, 4); + expect(playlistTrack.formattedDuration, '3:31'); + expect(playlistTrack.formattedPlayCount, '99.0K'); + expect(playlistTrack.isLiked, isTrue); + expect(playlistTrack.isUnavailable, isFalse); + expect(playlistTrack.toTrack().audioUrl, 'https://example.com/stream'); + }); + + test( + 'PlaylistEntity labels public/private playlist variants correctly', + () { + final playlist = PlaylistEntity( + id: 'playlist-1', + name: 'My Playlist', + ownerName: 'Hana', + ownerId: 'user-me', + isPublic: false, + type: PlaylistType.playlist, + trackCount: 3, + totalDuration: const Duration(minutes: 9, seconds: 25), + createdAt: DateTime(2024, 1, 1), + ); + + expect(playlist.typeLabel, 'Playlist'); + expect(playlist.subtitleLine, contains('3 tracks')); + expect(playlist.detailSubtitle, '3 tracks'); + expect(playlist.isPublic, isFalse); + + expect(playlist.copyWith(type: PlaylistType.album).typeLabel, 'Album'); + expect( + playlist.copyWith(type: PlaylistType.station).typeLabel, + 'Station', + ); + expect( + playlist.copyWith(type: PlaylistType.album).detailSubtitle, + contains('3 tracks'), + ); + expect( + playlist.copyWith(type: PlaylistType.station).detailSubtitle, + contains('3 tracks'), + ); + expect( + playlist.copyWith(type: PlaylistType.album).subtitleLine, + contains('3 tracks'), + ); + expect( + playlist.copyWith(type: PlaylistType.station).subtitleLine, + contains('3 tracks'), + ); + expect(playlist.copyWith(isGeneratedMix: true).typeLabel, 'Mix'); + expect( + playlist.copyWith(isTrackRadio: true).subtitleLine, + contains('3 tracks'), + ); + }, + ); + + test('copyWith can clear cover while preserving unrelated metadata', () { + final playlist = PlaylistEntity( + id: 'playlist-1', + name: 'My Playlist', + ownerName: 'Hana', + ownerId: 'user-me', + isPublic: true, + type: PlaylistType.playlist, + trackCount: 0, + totalDuration: Duration.zero, + createdAt: DateTime(2024, 1, 1), + coverUrl: 'https://example.com/cover.jpg', + ); + + final updated = playlist.copyWith( + name: 'Updated', + clearCover: true, + isPublic: false, + ); + + expect(updated.name, 'Updated'); + expect(updated.coverUrl, isNull); + expect(updated.ownerId, 'user-me'); + expect(updated.isPublic, isFalse); + }); + + test( + 'PlaylistTrack formats play counts and preserves copyWith defaults', + () { + const small = PlaylistTrack( + id: 'small', + title: 'Small', + artistName: 'Artist', + duration: Duration(seconds: 61), + playCount: 999, + position: 3, + streamUrl: 'stream', + audioUrl: 'audio', + ); + const thousands = PlaylistTrack( + id: 'thousands', + title: 'Thousands', + artistName: 'Artist', + duration: Duration.zero, + playCount: 1200, + position: 1, + ); + const millions = PlaylistTrack( + id: 'millions', + title: 'Millions', + artistName: 'Artist', + duration: Duration(seconds: 30), + playCount: 1500000, + position: 1, + ); + + expect(small.formattedDuration, '1:01'); + expect(small.formattedPlayCount, '999'); + expect(thousands.formattedPlayCount, '1.2K'); + expect(millions.formattedPlayCount, '1.5M'); + expect(small.copyWith().position, 3); + expect(small.copyWith(position: 4).position, 4); + expect(small.copyWith(isLiked: true).isLiked, isTrue); + expect(small.copyWith(streamUrl: 'new').streamUrl, 'new'); + expect(small.copyWith(audioUrl: 'new-audio').audioUrl, 'new-audio'); + }, + ); + }); +} + // import 'package:flutter_test/flutter_test.dart'; // import 'package:rythmify/features/playlist/domain/entities/playlist_entity.dart'; // import 'package:rythmify/features/playlist/domain/entities/playlist_track.dart'; diff --git a/test/features/playlist/playlist_usecases_test.dart b/test/features/playlist/playlist_usecases_test.dart new file mode 100644 index 00000000..d1b46db8 --- /dev/null +++ b/test/features/playlist/playlist_usecases_test.dart @@ -0,0 +1,338 @@ +import 'dart:io'; + +import 'package:dartz/dartz.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/core/error/failures.dart'; +import 'package:rythmify/features/playlist/domain/entities/collection_type.dart'; +import 'package:rythmify/features/playlist/domain/entities/playlist_entity.dart'; +import 'package:rythmify/features/playlist/domain/entities/playlist_track.dart'; +import 'package:rythmify/features/playlist/domain/entities/station_entity.dart'; +import 'package:rythmify/features/playlist/domain/repositories/playlist_repository.dart'; +import 'package:rythmify/features/playlist/domain/usecases/playlist_usecases.dart'; + +class MockPlaylistRepository extends Mock implements PlaylistRepository {} + +class FakeFile extends Fake implements File {} + +const _failure = PlaylistServerFailure(); + +final _playlist = PlaylistEntity( + id: 'playlist-1', + name: 'Night Drive', + ownerName: 'Owner', + ownerId: 'user-1', + isPublic: true, + type: PlaylistType.playlist, + trackCount: 1, + totalDuration: const Duration(minutes: 3), + createdAt: DateTime(2024, 1, 1), +); + +final _track = PlaylistTrack( + id: 'track-1', + title: 'Track', + artistName: 'Artist', + duration: const Duration(minutes: 3), + playCount: 10, + position: 1, +); + +final _station = StationEntity( + id: 'artist-1', + name: 'Artist Radio', + seedArtistId: 'artist-1', + seedArtistDisplayName: 'Artist', + trackCount: 25, +); + +void main() { + late MockPlaylistRepository repository; + + setUpAll(() { + registerFallbackValue(FakeFile()); + registerFallbackValue(CollectionType.playlist); + }); + + setUp(() { + repository = MockPlaylistRepository(); + }); + + group('Playlist use cases', () { + test('fetchMyPlaylists forwards pagination/filter arguments', () async { + when( + () => repository.fetchMyPlaylists( + filter: any(named: 'filter'), + albumView: any(named: 'albumView'), + limit: any(named: 'limit'), + offset: any(named: 'offset'), + ), + ).thenAnswer((_) async => Right([_playlist])); + + final result = await FetchMyPlaylistsUseCase(repository)( + filter: 'liked', + albumView: true, + limit: 10, + offset: 20, + ); + + expect(result.isRight(), isTrue); + expect(result.getOrElse(() => []), [_playlist]); + verify( + () => repository.fetchMyPlaylists( + filter: 'liked', + albumView: true, + limit: 10, + offset: 20, + ), + ).called(1); + }); + + test( + 'fetch detail and tracks pass secret tokens through repository', + () async { + when( + () => repository.fetchPlaylistDetail( + playlistId: any(named: 'playlistId'), + secretToken: any(named: 'secretToken'), + ), + ).thenAnswer((_) async => Right(_playlist)); + when( + () => repository.fetchPlaylistTracks( + playlistId: any(named: 'playlistId'), + secretToken: any(named: 'secretToken'), + page: any(named: 'page'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => Right([_track])); + + await FetchPlaylistDetailUseCase(repository)( + playlistId: 'private-1', + secretToken: 'secret-token', + ); + await FetchPlaylistTracksUseCase(repository)( + playlistId: 'private-1', + secretToken: 'secret-token', + page: 2, + limit: 5, + ); + + verify( + () => repository.fetchPlaylistDetail( + playlistId: 'private-1', + secretToken: 'secret-token', + ), + ).called(1); + verify( + () => repository.fetchPlaylistTracks( + playlistId: 'private-1', + secretToken: 'secret-token', + page: 2, + limit: 5, + ), + ).called(1); + }, + ); + + test( + 'update trims valid names and rejects empty names before repository', + () async { + when( + () => repository.updatePlaylist( + playlistId: any(named: 'playlistId'), + name: any(named: 'name'), + description: any(named: 'description'), + isPublic: any(named: 'isPublic'), + coverImage: any(named: 'coverImage'), + removeCover: any(named: 'removeCover'), + subtype: any(named: 'subtype'), + releaseDate: any(named: 'releaseDate'), + genreId: any(named: 'genreId'), + tags: any(named: 'tags'), + ), + ).thenAnswer((_) async => Right(_playlist.copyWith(name: 'New Name'))); + + final ok = await UpdatePlaylistUseCase(repository)( + playlistId: 'playlist-1', + name: ' New Name ', + description: 'desc', + isPublic: false, + ); + final invalid = await UpdatePlaylistUseCase(repository)( + playlistId: 'playlist-1', + name: ' ', + ); + + expect(ok.isRight(), isTrue); + expect( + invalid.fold((l) => l, (_) => null), + isA(), + ); + verify( + () => repository.updatePlaylist( + playlistId: 'playlist-1', + name: 'New Name', + description: 'desc', + isPublic: false, + coverImage: null, + removeCover: false, + subtype: null, + releaseDate: null, + genreId: null, + tags: null, + ), + ).called(1); + }, + ); + + test('delete/add/remove/reorder delegate and preserve failures', () async { + when( + () => repository.deletePlaylist('playlist-1'), + ).thenAnswer((_) async => const Right(null)); + when( + () => repository.addTrackToPlaylist( + playlistId: any(named: 'playlistId'), + trackId: any(named: 'trackId'), + position: any(named: 'position'), + ), + ).thenAnswer((_) async => const Right(null)); + when( + () => repository.removeTrackFromPlaylist( + playlistId: any(named: 'playlistId'), + trackId: any(named: 'trackId'), + ), + ).thenAnswer((_) async => const Left(_failure)); + when( + () => repository.reorderPlaylistTracks( + playlistId: any(named: 'playlistId'), + orderedTrackIds: any(named: 'orderedTrackIds'), + ), + ).thenAnswer((_) async => const Right(null)); + + expect( + (await DeletePlaylistUseCase(repository)('playlist-1')).isRight(), + isTrue, + ); + expect( + (await AddTrackToPlaylistUseCase(repository)( + playlistId: 'playlist-1', + trackId: 'track-1', + position: 2, + )).isRight(), + isTrue, + ); + expect( + await RemoveTrackFromPlaylistUseCase(repository)( + playlistId: 'playlist-1', + trackId: 'track-1', + ), + const Left(_failure), + ); + expect( + (await ReorderPlaylistTracksUseCase(repository)( + playlistId: 'playlist-1', + orderedTrackIds: ['track-2', 'track-1'], + )).isRight(), + isTrue, + ); + + verify(() => repository.deletePlaylist('playlist-1')).called(1); + verify( + () => repository.addTrackToPlaylist( + playlistId: 'playlist-1', + trackId: 'track-1', + position: 2, + ), + ).called(1); + }); + + test('reorder rejects an empty order before calling repository', () async { + final result = await ReorderPlaylistTracksUseCase(repository)( + playlistId: 'playlist-1', + orderedTrackIds: const [], + ); + + expect( + result.fold((l) => l, (_) => null), + isA(), + ); + verifyNever( + () => repository.reorderPlaylistTracks( + playlistId: any(named: 'playlistId'), + orderedTrackIds: any(named: 'orderedTrackIds'), + ), + ); + }); + + test( + 'toggle like/repost choose inverse action from current UI state', + () async { + when( + () => repository.likePlaylist('playlist-1'), + ).thenAnswer((_) async => const Right(null)); + when( + () => repository.unlikePlaylist('playlist-1'), + ).thenAnswer((_) async => const Right(null)); + when( + () => repository.repostPlaylist('playlist-1'), + ).thenAnswer((_) async => const Right(null)); + when( + () => repository.removePlaylistRepost('playlist-1'), + ).thenAnswer((_) async => const Right(null)); + + await TogglePlaylistLikeUseCase(repository)( + playlistId: 'playlist-1', + liked: false, + ); + await TogglePlaylistLikeUseCase(repository)( + playlistId: 'playlist-1', + liked: true, + ); + await TogglePlaylistRepostUseCase(repository)( + playlistId: 'playlist-1', + reposted: false, + ); + await TogglePlaylistRepostUseCase(repository)( + playlistId: 'playlist-1', + reposted: true, + ); + + verify(() => repository.likePlaylist('playlist-1')).called(1); + verify(() => repository.unlikePlaylist('playlist-1')).called(1); + verify(() => repository.repostPlaylist('playlist-1')).called(1); + verify(() => repository.removePlaylistRepost('playlist-1')).called(1); + }, + ); + + test('station use cases forward pagination and results', () async { + when( + () => repository.fetchStations( + limit: any(named: 'limit'), + offset: any(named: 'offset'), + ), + ).thenAnswer((_) async => Right([_station])); + when( + () => repository.fetchStationTracks( + artistId: any(named: 'artistId'), + limit: any(named: 'limit'), + offset: any(named: 'offset'), + ), + ).thenAnswer((_) async => Right([_track])); + + final stations = await FetchStationsUseCase(repository)( + limit: 4, + offset: 8, + ); + final stationTracks = await FetchStationTracksUseCase(repository)( + artistId: 'artist-1', + limit: 9, + offset: 3, + ); + + expect(stations.isRight(), isTrue); + expect(stations.getOrElse(() => []), [_station]); + expect(stationTracks.isRight(), isTrue); + expect(stationTracks.getOrElse(() => []), [_track]); + }); + }); +} diff --git a/test/features/track_upload/upload_form_provider_test.dart b/test/features/track_upload/upload_form_provider_test.dart new file mode 100644 index 00000000..078e161c --- /dev/null +++ b/test/features/track_upload/upload_form_provider_test.dart @@ -0,0 +1,545 @@ +import 'dart:io'; + +import 'package:dartz/dartz.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/core/domain/entities/track.dart'; +import 'package:rythmify/core/error/failures.dart'; +import 'package:rythmify/features/track_upload/data/datasources/upload_track_remote_datasource.dart'; +import 'package:rythmify/features/track_upload/domain/entities/track_draft.dart'; +import 'package:rythmify/features/track_upload/domain/repositories/upload_track_repository.dart'; +import 'package:rythmify/features/track_upload/domain/usecases/upload_track_usecase.dart'; +import 'package:rythmify/features/track_upload/presentation/providers/upload_track_provider.dart'; + +class MockUploadTrackRepository extends Mock implements UploadTrackRepository {} + +class MockUploadTrackRemoteDataSource extends Mock + implements UploadTrackRemoteDataSource {} + +class FakeTrackDraft extends Fake implements TrackDraft {} + +class FakeFile extends Fake implements File {} + +class FakeProgressCallback extends Fake { + void call(double progress) {} +} + +ProviderContainer _container() { + final container = ProviderContainer(); + addTearDown(container.dispose); + return container; +} + +TrackDraft _draft() { + return TrackDraft( + artistId: 'user-1', + localAudioPath: '/music/song.mp3', + duration: const Duration(minutes: 3), + title: 'Song', + artist: 'Artist', + genre: 'Pop', + ); +} + +void main() { + setUpAll(() { + registerFallbackValue(FakeTrackDraft()); + registerFallbackValue(FakeFile()); + registerFallbackValue(FakeProgressCallback().call); + }); + + group('UploadFormState', () { + test('canSave requires a draft with non-empty title and artist', () { + expect(const UploadFormState().canSave, isFalse); + expect(UploadFormState(draft: _draft()).canSave, isTrue); + expect( + UploadFormState(draft: _draft().copyWith(title: ' ')).canSave, + isFalse, + ); + expect( + UploadFormState(draft: _draft().copyWith(artist: '')).canSave, + isFalse, + ); + }); + + test('copyWith preserves error unless explicitly cleared', () { + const state = UploadFormState(errorMessage: 'failed'); + + expect(state.copyWith(currentTab: 1).errorMessage, 'failed'); + expect(state.copyWith(clearError: true).errorMessage, isNull); + }); + }); + + group('UploadFormNotifier draft editing', () { + test( + 'initDraft derives title from filename and starts audio uploading state', + () { + final container = _container(); + final notifier = container.read(uploadFormProvider.notifier); + + notifier.initDraft( + artistId: 'artist-1', + artistName: 'Artist', + localAudioPath: '/music/night.drive.wav', + duration: const Duration(seconds: 210), + fileName: 'night.drive.wav', + ); + + final draft = container.read(uploadFormProvider).draft!; + expect(draft.artistId, 'artist-1'); + expect(draft.localAudioPath, '/music/night.drive.wav'); + expect(draft.audioFileName, 'night.drive.wav'); + expect(draft.title, 'night.drive'); + expect(draft.artist, 'Artist'); + expect(draft.audioStatus, UploadStatus.uploading); + expect(draft.audioUploadProgress, 0); + }, + ); + + test('field setters propagate metadata and visibility to draft', () { + final container = _container(); + final notifier = container.read(uploadFormProvider.notifier); + notifier.initDraft( + artistId: 'artist-1', + artistName: 'Artist', + localAudioPath: '/music/song.mp3', + duration: const Duration(seconds: 180), + fileName: 'song.mp3', + ); + + notifier + ..setTitle('New Title') + ..setArtist('New Artist') + ..setGenre('House') + ..setDescription('desc') + ..setCaption('caption') + ..setIsPublic(false) + ..setIsHidden(true) + ..setArtwork('/covers/cover.jpg') + ..setGeoRestrictionType('include') + ..toggleGeoRegion('EG') + ..toggleGeoRegion('US') + ..toggleGeoRegion('US') + ..setUploadProgress(.5); + + final draft = container.read(uploadFormProvider).draft!; + expect(draft.title, 'New Title'); + expect(draft.artist, 'New Artist'); + expect(draft.genre, 'House'); + expect(draft.description, 'desc'); + expect(draft.caption, 'caption'); + expect(draft.isPublic, isFalse); + expect(draft.isHidden, isTrue); + expect(draft.localArtworkPath, '/covers/cover.jpg'); + expect(draft.geoRestrictionType, 'include'); + expect(draft.geoRegions, ['EG']); + expect(draft.status, UploadStatus.uploading); + expect(draft.uploadProgress, .5); + + notifier.setUploadProgress(1); + expect( + container.read(uploadFormProvider).draft!.status, + UploadStatus.success, + ); + }); + + test('tag operations prevent duplicates and support removal', () { + final container = _container(); + final notifier = container.read(uploadFormProvider.notifier); + notifier.initDraft( + artistId: 'artist-1', + artistName: 'Artist', + localAudioPath: '/music/song.mp3', + duration: const Duration(seconds: 180), + fileName: 'song.mp3', + ); + + notifier + ..setAvailableTags(['pop', 'club']) + ..addTag('pop') + ..addTag('pop') + ..addTag('club') + ..removeTag('pop') + ..setTab(2); + + final state = container.read(uploadFormProvider); + expect(state.availableTags, ['pop', 'club']); + expect(state.draft!.tags, ['club']); + expect(state.currentTab, 2); + }); + + test( + 'fetchTags and fetchGenres populate options and ignore failures', + () async { + final datasource = MockUploadTrackRemoteDataSource(); + when( + () => datasource.fetchTags(), + ).thenAnswer((_) async => ['pop', 'club']); + when( + () => datasource.fetchGenres(), + ).thenAnswer((_) async => ['Pop', 'House']); + + final container = _container(); + final notifier = container.read(uploadFormProvider.notifier); + await notifier.fetchTags(null, datasource); + await notifier.fetchGenres(null, datasource); + + expect(container.read(uploadFormProvider).availableTags, [ + 'pop', + 'club', + ]); + expect(container.read(uploadFormProvider).availableGenres, [ + 'Pop', + 'House', + ]); + + when(() => datasource.fetchTags()).thenThrow(Exception('tags down')); + when( + () => datasource.fetchGenres(), + ).thenThrow(Exception('genres down')); + await notifier.fetchTags(null, datasource); + await notifier.fetchGenres(null, datasource); + + expect(container.read(uploadFormProvider).availableTags, [ + 'pop', + 'club', + ]); + expect(container.read(uploadFormProvider).availableGenres, [ + 'Pop', + 'House', + ]); + }, + ); + + test('removeArtwork, clearGeoRegions, and reset clear state', () { + final container = _container(); + final notifier = container.read(uploadFormProvider.notifier); + notifier.initDraft( + artistId: 'artist-1', + artistName: 'Artist', + localAudioPath: '/music/song.mp3', + duration: const Duration(seconds: 180), + fileName: 'song.mp3', + ); + + notifier + ..setArtwork('/covers/cover.jpg') + ..toggleGeoRegion('EG') + ..removeArtwork() + ..clearGeoRegions(); + + expect( + container.read(uploadFormProvider).draft!.localArtworkPath, + isNull, + ); + expect(container.read(uploadFormProvider).draft!.geoRegions, isEmpty); + + notifier.reset(); + expect(container.read(uploadFormProvider).draft, isNull); + }); + + test('startAudioUpload progresses audio state to success', () async { + final container = _container(); + final notifier = container.read(uploadFormProvider.notifier); + notifier.initDraft( + artistId: 'artist-1', + artistName: 'Artist', + localAudioPath: '/music/song.mp3', + duration: const Duration(seconds: 180), + fileName: 'song.mp3', + ); + + notifier.startAudioUpload(); + await Future.delayed(const Duration(milliseconds: 4300)); + + final draft = container.read(uploadFormProvider).draft!; + expect(draft.audioStatus, UploadStatus.success); + expect(draft.audioUploadProgress, 1); + }); + + test('initFromTrack maps existing track into update draft', () { + final container = _container(); + final notifier = container.read(uploadFormProvider.notifier); + final track = Track( + id: 'track-1', + userId: 'artist-1', + title: 'Existing', + artist: 'Artist', + audioUrl: 'https://cdn/audio.mp3', + duration: const Duration(seconds: 200), + createdAt: DateTime(2024, 1, 1), + coverImage: 'https://cdn/cover.jpg', + genre: 'Pop', + tags: const ['demo'], + description: 'desc', + isPublic: false, + isHidden: true, + geoRestrictionType: 'include', + geoRegions: const ['EG'], + ); + + notifier.initFromTrack(track); + + final draft = container.read(uploadFormProvider).draft!; + expect(draft.trackId, 'track-1'); + expect(draft.remoteArtworkUrl, 'https://cdn/cover.jpg'); + expect(draft.localAudioPath, ''); + expect(draft.audioStatus, UploadStatus.success); + expect(draft.isPublic, isFalse); + expect(draft.isHidden, isTrue); + expect(draft.geoRegions, ['EG']); + }); + }); + + group('UploadFormNotifier startUpload', () { + test( + 'transitions uploading progress to success and calls onSuccess', + () async { + final repository = MockUploadTrackRepository(); + final useCase = UploadTrackUseCase(repository, fileExists: (_) => true); + when( + () => repository.uploadTrack( + draft: any(named: 'draft'), + audioFile: any(named: 'audioFile'), + artworkFile: any(named: 'artworkFile'), + onProgress: any(named: 'onProgress'), + ), + ).thenAnswer((invocation) async { + final callback = + invocation.namedArguments[#onProgress] as void Function(double)?; + callback?.call(.4); + return const Right('track-1'); + }); + final container = _container(); + final notifier = container.read(uploadFormProvider.notifier); + notifier.initDraft( + artistId: 'artist-1', + artistName: 'Artist', + localAudioPath: '/music/song.mp3', + duration: const Duration(seconds: 180), + fileName: 'song.mp3', + ); + notifier.setGenre('Pop'); + var successId = ''; + var error = ''; + + await notifier.startUpload( + useCaseOverride: useCase, + onSuccess: (trackId) => successId = trackId, + onError: (message) => error = message, + ); + + final draft = container.read(uploadFormProvider).draft!; + expect(successId, 'track-1'); + expect(error, isEmpty); + expect(draft.status, UploadStatus.success); + expect(draft.uploadProgress, 1); + }, + ); + + test( + 'transitions to error and preserves failure message on upload failure', + () async { + final repository = MockUploadTrackRepository(); + final useCase = UploadTrackUseCase(repository, fileExists: (_) => true); + when( + () => repository.uploadTrack( + draft: any(named: 'draft'), + audioFile: any(named: 'audioFile'), + artworkFile: any(named: 'artworkFile'), + onProgress: any(named: 'onProgress'), + ), + ).thenAnswer((_) async => const Left(UploadLimitFailure())); + final container = _container(); + final notifier = container.read(uploadFormProvider.notifier); + notifier.initDraft( + artistId: 'artist-1', + artistName: 'Artist', + localAudioPath: '/music/song.mp3', + duration: const Duration(seconds: 180), + fileName: 'song.mp3', + ); + notifier.setGenre('Pop'); + var error = ''; + + await notifier.startUpload( + useCaseOverride: useCase, + onSuccess: (_) {}, + onError: (message) => error = message, + ); + + expect( + container.read(uploadFormProvider).draft!.status, + UploadStatus.error, + ); + expect(error, 'Upload limit reached. Try again later.'); + }, + ); + + test( + 'reports service unavailable when no ref or use case is supplied', + () async { + final container = _container(); + final notifier = container.read(uploadFormProvider.notifier); + notifier.initDraft( + artistId: 'artist-1', + artistName: 'Artist', + localAudioPath: '/music/song.mp3', + duration: const Duration(seconds: 180), + fileName: 'song.mp3', + ); + var error = ''; + + await notifier.startUpload( + onSuccess: (_) {}, + onError: (message) => error = message, + ); + + expect( + container.read(uploadFormProvider).draft!.status, + UploadStatus.error, + ); + expect(error, 'Upload service unavailable.'); + }, + ); + + test('production provider chain resolves upload use case', () { + final container = _container(); + expect(container.read(uploadUseCaseProvider), isA()); + }); + }); + + group('UploadFormNotifier update/delete flows', () { + test( + 'handleUpdate sends editable metadata and clears loading on success', + () async { + final container = _container(); + final notifier = container.read(uploadFormProvider.notifier); + notifier.initFromTrack( + Track( + id: 'track-1', + userId: 'artist-1', + title: 'Existing', + artist: 'Artist', + audioUrl: 'https://cdn/audio.mp3', + duration: const Duration(seconds: 200), + createdAt: DateTime(2024, 1, 1), + genre: 'Pop', + tags: const ['demo'], + isPublic: false, + isHidden: true, + geoRestrictionType: 'include', + geoRegions: const ['EG'], + ), + ); + Map? sentData; + var success = false; + + await notifier.handleUpdate( + updateOverride: (trackId, data) async { + expect(trackId, 'track-1'); + sentData = data; + }, + onSuccess: () => success = true, + onError: (_) {}, + ); + + expect(success, isTrue); + expect(container.read(uploadFormProvider).isLoading, isFalse); + expect(sentData?['title'], 'Existing'); + expect(sentData?['genre'], 'Pop'); + expect(sentData?['is_public'], isFalse); + expect(sentData?['geo_regions'], ['EG']); + }, + ); + + test('handleUpdate and handleDelete surface failures', () async { + final container = _container(); + final notifier = container.read(uploadFormProvider.notifier); + notifier.initFromTrack( + Track( + id: 'track-1', + userId: 'artist-1', + title: 'Existing', + artist: 'Artist', + audioUrl: 'https://cdn/audio.mp3', + duration: const Duration(seconds: 200), + createdAt: DateTime(2024, 1, 1), + ), + ); + final errors = []; + + await notifier.handleUpdate( + updateOverride: (_, __) async => throw Exception('update failed'), + onSuccess: () {}, + onError: errors.add, + ); + await notifier.handleDelete( + deleteOverride: (_) async => throw Exception('delete failed'), + onSuccess: () {}, + onError: errors.add, + ); + + expect(container.read(uploadFormProvider).isLoading, isFalse); + expect(errors[0], contains('update failed')); + expect(errors[1], contains('delete failed')); + }); + + test('handleUpdate and handleDelete report unavailable services', () async { + final container = _container(); + final notifier = container.read(uploadFormProvider.notifier); + notifier.initFromTrack( + Track( + id: 'track-1', + userId: 'artist-1', + title: 'Existing', + artist: 'Artist', + audioUrl: 'https://cdn/audio.mp3', + duration: const Duration(seconds: 200), + createdAt: DateTime(2024, 1, 1), + ), + ); + final errors = []; + + await notifier.handleUpdate(onSuccess: () {}, onError: errors.add); + await notifier.handleDelete(onSuccess: () {}, onError: errors.add); + + expect(errors, [ + 'Update service unavailable.', + 'Delete service unavailable.', + ]); + }); + + test( + 'handleDelete calls delete service and returns to idle on success', + () async { + final container = _container(); + final notifier = container.read(uploadFormProvider.notifier); + notifier.initFromTrack( + Track( + id: 'track-1', + userId: 'artist-1', + title: 'Existing', + artist: 'Artist', + audioUrl: 'https://cdn/audio.mp3', + duration: const Duration(seconds: 200), + createdAt: DateTime(2024, 1, 1), + ), + ); + var deleted = ''; + var success = false; + + await notifier.handleDelete( + deleteOverride: (trackId) async => deleted = trackId, + onSuccess: () => success = true, + onError: (_) {}, + ); + + expect(deleted, 'track-1'); + expect(success, isTrue); + expect(container.read(uploadFormProvider).isLoading, isFalse); + }, + ); + }); +} diff --git a/test/features/track_upload/upload_models_and_entities_test.dart b/test/features/track_upload/upload_models_and_entities_test.dart new file mode 100644 index 00000000..d6b475af --- /dev/null +++ b/test/features/track_upload/upload_models_and_entities_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/track_upload/data/models/upload_response_model.dart'; +import 'package:rythmify/features/track_upload/domain/entities/track_draft.dart'; + +void main() { + group('UploadResponseModel', () { + test('parses direct upload response', () { + final result = UploadResponseModel.fromJson({ + 'id': 'track-1', + 'status': 'ready', + }); + + expect(result.id, 'track-1'); + expect(result.status, 'ready'); + }); + + test('parses nested track response and defaults processing status', () { + final result = UploadResponseModel.fromJson({ + 'track': {'id': 'track-2'}, + }); + + expect(result.id, 'track-2'); + expect(result.status, 'processing'); + }); + + test('handles malformed response without throwing', () { + final result = UploadResponseModel.fromJson({'message': 'created'}); + + expect(result.id, ''); + expect(result.status, 'processing'); + }); + }); + + group('TrackDraft', () { + test('checklistCount reflects filled metadata and artwork', () { + final draft = TrackDraft( + artistId: 'user-1', + localAudioPath: '/music/audio.mp3', + duration: const Duration(minutes: 3), + title: 'Song', + artist: 'Artist', + genre: 'Pop', + localArtworkPath: '/covers/song.jpg', + description: 'Description', + ); + + expect(draft.checklistCount, 4); + }); + + test('remote artwork counts and blank fields are ignored', () { + final draft = TrackDraft( + artistId: 'user-1', + localAudioPath: '/music/audio.mp3', + duration: const Duration(minutes: 3), + remoteArtworkUrl: 'https://cdn/cover.jpg', + title: ' ', + artist: 'Artist', + genre: '', + ); + + expect(draft.checklistCount, 1); + }); + + test( + 'copyWith preserves state and supports clearing optional media/text', + () { + final original = TrackDraft( + trackId: 'track-1', + artistId: 'user-1', + localAudioPath: '/music/audio.mp3', + duration: const Duration(minutes: 3), + title: 'Old', + artist: 'Artist', + genre: 'Pop', + localArtworkPath: '/covers/song.jpg', + description: 'desc', + caption: 'caption', + tags: const ['demo'], + isPublic: false, + isHidden: true, + geoRestrictionType: 'include', + geoRegions: const ['EG'], + status: UploadStatus.uploading, + uploadProgress: .4, + ); + + final updated = original.copyWith( + title: 'New', + clearArtwork: true, + clearDescription: true, + clearCaption: true, + status: UploadStatus.success, + uploadProgress: 1, + ); + + expect(updated.trackId, 'track-1'); + expect(updated.title, 'New'); + expect(updated.localArtworkPath, isNull); + expect(updated.description, isNull); + expect(updated.caption, isNull); + expect(updated.tags, ['demo']); + expect(updated.isPublic, isFalse); + expect(updated.isHidden, isTrue); + expect(updated.geoRestrictionType, 'include'); + expect(updated.geoRegions, ['EG']); + expect(updated.status, UploadStatus.success); + expect(updated.uploadProgress, 1); + }, + ); + }); +} diff --git a/test/features/track_upload/upload_remote_datasource_test.dart b/test/features/track_upload/upload_remote_datasource_test.dart new file mode 100644 index 00000000..94b86fc2 --- /dev/null +++ b/test/features/track_upload/upload_remote_datasource_test.dart @@ -0,0 +1,355 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/track_upload/data/datasources/upload_track_remote_datasource.dart'; + +class MockDio extends Mock implements Dio {} + +Response> _jsonResponse( + String path, + Map data, +) { + return Response>( + requestOptions: RequestOptions(path: path), + statusCode: 200, + data: data, + ); +} + +void main() { + late MockDio dio; + late UploadTrackRemoteDataSource datasource; + final multipartRequests = <({String path, DioMediaType contentType})>[]; + + setUpAll(() { + registerFallbackValue(Options()); + registerFallbackValue(FormData()); + }); + + setUp(() { + dio = MockDio(); + multipartRequests.clear(); + datasource = UploadTrackRemoteDataSource( + dio: dio, + multipartFileFactory: (path, contentType) async { + multipartRequests.add((path: path, contentType: contentType)); + return MultipartFile.fromBytes([ + 1, + 2, + 3, + ], filename: path.split('/').last); + }, + ); + }); + + group('UploadTrackRemoteDataSource taxonomy calls', () { + test('default constructor is available for production wiring', () { + expect(UploadTrackRemoteDataSource(), isA()); + }); + + test( + 'fetchTags parses deployed list shape with strings and maps', + () async { + when(() => dio.get('/tags')).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/tags'), + data: { + 'data': [ + 'ambient', + {'name': 'club'}, + {'name': ''}, + 42, + ], + }, + ), + ); + + final result = await datasource.fetchTags(); + + expect(result, ['ambient', 'club']); + verify(() => dio.get('/tags')).called(1); + }, + ); + + test('fetchTags parses legacy items shape', () async { + when(() => dio.get('/tags')).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/tags'), + data: { + 'data': { + 'items': [ + {'name': 'indie'}, + ], + }, + }, + ), + ); + + expect(await datasource.fetchTags(), ['indie']); + }); + + test( + 'fetchGenres parses both deployed and legacy response shapes', + () async { + when(() => dio.get('/genres')).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/genres'), + data: { + 'data': [ + {'name': 'Pop'}, + {'name': ''}, + {'id': 'missing-name'}, + ], + }, + ), + ); + + expect(await datasource.fetchGenres(), ['Pop']); + + when(() => dio.get('/genres')).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/genres'), + data: { + 'data': { + 'items': [ + {'name': 'House'}, + ], + }, + }, + ), + ); + + expect(await datasource.fetchGenres(), ['House']); + }, + ); + + test('maps taxonomy Dio errors to coded exceptions', () async { + when(() => dio.get('/tags')).thenThrow( + DioException( + requestOptions: RequestOptions(path: '/tags'), + response: Response( + requestOptions: RequestOptions(path: '/tags'), + statusCode: 401, + data: { + 'error': {'message': 'login required'}, + }, + ), + ), + ); + + expect( + () => datasource.fetchTags(), + throwsA(predicate((e) => e.toString().contains('AUTH_401'))), + ); + }); + + test('fetchGenres maps Dio failures to coded exceptions', () async { + when(() => dio.get('/genres')).thenThrow( + DioException( + requestOptions: RequestOptions(path: '/genres'), + response: Response( + requestOptions: RequestOptions(path: '/genres'), + statusCode: 429, + data: {'message': 'slow down'}, + ), + ), + ); + + expect( + () => datasource.fetchGenres(), + throwsA(predicate((e) => e.toString().contains('RATE_LIMIT_429'))), + ); + }); + }); + + group('UploadTrackRemoteDataSource uploadTrack', () { + test( + 'builds multipart fields, repeats arrays, and reports progress', + () async { + FormData? sentForm; + final progress = []; + when( + () => dio.post( + '/tracks', + data: any(named: 'data'), + options: any(named: 'options'), + onSendProgress: any(named: 'onSendProgress'), + ), + ).thenAnswer((invocation) async { + sentForm = invocation.namedArguments[#data] as FormData; + final callback = + invocation.namedArguments[#onSendProgress] as ProgressCallback?; + callback?.call(50, 100); + return _jsonResponse('/tracks', { + 'track': {'id': 'track-1', 'status': 'processing'}, + }); + }); + + final result = await datasource.uploadTrack( + audioFile: File('/tmp/song.wav'), + artworkFile: File('/tmp/cover.png'), + title: 'Song', + artist: 'Artist', + genre: 'Pop', + description: 'Description', + caption: 'Caption', + tags: const ['tag-a', 'tag-b'], + isPublic: false, + isHidden: true, + geoRestrictionType: 'include', + geoRegions: const ['EG', 'US'], + onProgress: progress.add, + ); + + expect(result.id, 'track-1'); + expect(result.status, 'processing'); + expect(progress, [.5]); + expect(multipartRequests.map((r) => r.path), [ + '/tmp/song.wav', + '/tmp/cover.png', + ]); + + final fields = Map.fromEntries(sentForm!.fields); + expect(fields['title'], 'Song'); + expect(fields['artists'], 'Artist'); + expect(fields['genre'], 'Pop'); + expect(fields['is_public'], 'false'); + expect(fields['is_hidden'], 'true'); + expect(fields['description'], 'Description'); + expect(fields['geo_restriction_type'], 'include'); + expect( + sentForm!.fields.where((e) => e.key == 'tags[]').map((e) => e.value), + ['tag-a', 'tag-b'], + ); + expect( + sentForm!.fields + .where((e) => e.key == 'geo_regions[]') + .map((e) => e.value), + ['EG', 'US'], + ); + expect(sentForm!.files.map((f) => f.key), [ + 'audio_file', + 'cover_image', + ]); + }, + ); + + test('omits optional fields and artwork when they are empty', () async { + FormData? sentForm; + when( + () => dio.post( + '/tracks', + data: any(named: 'data'), + options: any(named: 'options'), + onSendProgress: any(named: 'onSendProgress'), + ), + ).thenAnswer((invocation) async { + sentForm = invocation.namedArguments[#data] as FormData; + return _jsonResponse('/tracks', {'id': 'track-2', 'status': 'ready'}); + }); + + await datasource.uploadTrack( + audioFile: File('/tmp/song.mp3'), + title: 'Song', + artist: 'Artist', + genre: '', + description: '', + tags: const [], + isPublic: true, + ); + + final fields = Map.fromEntries(sentForm!.fields); + expect(fields.containsKey('description'), isFalse); + expect(fields.containsKey('geo_restriction_type'), isFalse); + expect(sentForm!.files.map((f) => f.key), ['audio_file']); + }); + + test( + 'maps upload HTTP failures to coded exceptions for repository', + () async { + when( + () => dio.post( + '/tracks', + data: any(named: 'data'), + options: any(named: 'options'), + onSendProgress: any(named: 'onSendProgress'), + ), + ).thenThrow( + DioException( + requestOptions: RequestOptions(path: '/tracks'), + response: Response( + requestOptions: RequestOptions(path: '/tracks'), + statusCode: 413, + data: {'message': 'too big'}, + ), + ), + ); + + expect( + () => datasource.uploadTrack( + audioFile: File('/tmp/song.mp3'), + title: 'Song', + artist: 'Artist', + genre: 'Pop', + tags: const [], + isPublic: true, + ), + throwsA( + predicate((e) => e.toString().contains('FILE_TOO_LARGE_413')), + ), + ); + }, + ); + + test( + 'maps upload auth, unsupported media, rate, and unknown errors', + () async { + Future errorFor(int? statusCode) async { + when( + () => dio.post( + '/tracks', + data: any(named: 'data'), + options: any(named: 'options'), + onSendProgress: any(named: 'onSendProgress'), + ), + ).thenThrow( + DioException( + requestOptions: RequestOptions(path: '/tracks'), + response: statusCode == null + ? null + : Response( + requestOptions: RequestOptions(path: '/tracks'), + statusCode: statusCode, + data: { + 'error': {'message': 'server said no'}, + }, + ), + message: 'socket closed', + ), + ); + try { + await datasource.uploadTrack( + audioFile: File('/tmp/song.mp3'), + title: 'Song', + artist: 'Artist', + genre: 'Pop', + tags: const [], + isPublic: true, + ); + return ''; + } catch (e) { + reset(dio); + return e.toString(); + } + } + + expect(await errorFor(403), contains('UPLOAD_LIMIT_403')); + expect(await errorFor(415), contains('UNSUPPORTED_FILE_415')); + expect(await errorFor(429), contains('RATE_LIMIT_429')); + expect(await errorFor(null), contains('SERVER_UNKNOWN')); + }, + ); + }); +} diff --git a/test/features/track_upload/upload_repository_impl_test.dart b/test/features/track_upload/upload_repository_impl_test.dart new file mode 100644 index 00000000..3bd36e60 --- /dev/null +++ b/test/features/track_upload/upload_repository_impl_test.dart @@ -0,0 +1,185 @@ +import 'dart:io'; + +import 'package:dartz/dartz.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/core/error/failures.dart'; +import 'package:rythmify/features/track_upload/data/datasources/upload_track_remote_datasource.dart'; +import 'package:rythmify/features/track_upload/data/models/upload_response_model.dart'; +import 'package:rythmify/features/track_upload/data/repositories/upload_track_repository_impl.dart'; +import 'package:rythmify/features/track_upload/domain/entities/track_draft.dart'; + +class MockUploadTrackRemoteDataSource extends Mock + implements UploadTrackRemoteDataSource {} + +class FakeFile extends Fake implements File {} + +class FakeProgressCallback extends Fake { + void call(double progress) {} +} + +TrackDraft _draft({ + String? title = 'Song', + String? artist = 'Artist', + String? genre = 'Pop', +}) { + return TrackDraft( + artistId: 'user-1', + localAudioPath: '/tmp/song.mp3', + duration: const Duration(minutes: 3), + title: title, + artist: artist, + genre: genre, + description: 'desc', + caption: 'caption', + tags: const ['tag'], + isPublic: false, + isHidden: true, + geoRestrictionType: 'include', + geoRegions: const ['EG'], + ); +} + +void main() { + late MockUploadTrackRemoteDataSource datasource; + late UploadTrackRepositoryImpl repository; + + setUpAll(() { + registerFallbackValue(FakeFile()); + registerFallbackValue(FakeProgressCallback().call); + }); + + setUp(() { + datasource = MockUploadTrackRemoteDataSource(); + repository = UploadTrackRepositoryImpl(dataSource: datasource); + }); + + group('UploadTrackRepositoryImpl', () { + test('fetchTags returns datasource tags', () async { + when( + () => datasource.fetchTags(), + ).thenAnswer((_) async => ['pop', 'rock']); + + final result = await repository.fetchTags(); + expect(result.isRight(), isTrue); + expect(result.getOrElse(() => []), ['pop', 'rock']); + verify(() => datasource.fetchTags()).called(1); + }); + + test('fetchTags maps auth failures', () async { + when( + () => datasource.fetchTags(), + ).thenThrow(Exception('AUTH_401: token expired')); + + final result = await repository.fetchTags(); + + expect(result.fold((l) => l, (_) => null), isA()); + }); + + test( + 'uploadTrack forwards metadata/files and returns uploaded track id', + () async { + final progress = []; + when( + () => datasource.uploadTrack( + audioFile: any(named: 'audioFile'), + artworkFile: any(named: 'artworkFile'), + title: any(named: 'title'), + artist: any(named: 'artist'), + genre: any(named: 'genre'), + description: any(named: 'description'), + caption: any(named: 'caption'), + tags: any(named: 'tags'), + isPublic: any(named: 'isPublic'), + isHidden: any(named: 'isHidden'), + geoRestrictionType: any(named: 'geoRestrictionType'), + geoRegions: any(named: 'geoRegions'), + onProgress: any(named: 'onProgress'), + ), + ).thenAnswer((invocation) async { + final callback = + invocation.namedArguments[#onProgress] as void Function(double)?; + callback?.call(.75); + return const UploadResponseModel(id: 'track-1', status: 'processing'); + }); + + final result = await repository.uploadTrack( + draft: _draft(), + audioFile: File('/tmp/song.mp3'), + artworkFile: File('/tmp/cover.jpg'), + onProgress: progress.add, + ); + + expect(result, const Right('track-1')); + expect(progress, [.75]); + verify( + () => datasource.uploadTrack( + audioFile: any(named: 'audioFile'), + artworkFile: any(named: 'artworkFile'), + title: 'Song', + artist: 'Artist', + genre: 'Pop', + description: 'desc', + caption: 'caption', + tags: ['tag'], + isPublic: false, + isHidden: true, + geoRestrictionType: 'include', + geoRegions: ['EG'], + onProgress: any(named: 'onProgress'), + ), + ).called(1); + }, + ); + + test('maps upload error codes to domain failures', () async { + Future uploadFailureFor(Object error) async { + when( + () => datasource.uploadTrack( + audioFile: any(named: 'audioFile'), + artworkFile: any(named: 'artworkFile'), + title: any(named: 'title'), + artist: any(named: 'artist'), + genre: any(named: 'genre'), + description: any(named: 'description'), + caption: any(named: 'caption'), + tags: any(named: 'tags'), + isPublic: any(named: 'isPublic'), + isHidden: any(named: 'isHidden'), + geoRestrictionType: any(named: 'geoRestrictionType'), + geoRegions: any(named: 'geoRegions'), + onProgress: any(named: 'onProgress'), + ), + ).thenThrow(error); + + final result = await repository.uploadTrack( + draft: _draft(), + audioFile: File('/tmp/song.mp3'), + ); + reset(datasource); + return result.fold((l) => l, (_) => throw StateError('expected left')); + } + + expect( + await uploadFailureFor(Exception('UPLOAD_LIMIT_403')), + isA(), + ); + expect( + await uploadFailureFor(Exception('FILE_TOO_LARGE_413')), + isA(), + ); + expect( + await uploadFailureFor(Exception('UNSUPPORTED_FILE_415')), + isA(), + ); + expect( + await uploadFailureFor(const SocketException('offline')), + isA(), + ); + expect( + await uploadFailureFor(Exception('SERVER_500')), + isA(), + ); + }); + }); +} diff --git a/test/features/track_upload/upload_track_usecase_test.dart b/test/features/track_upload/upload_track_usecase_test.dart index 82f19bfa..8f9f21c3 100644 --- a/test/features/track_upload/upload_track_usecase_test.dart +++ b/test/features/track_upload/upload_track_usecase_test.dart @@ -1,75 +1,162 @@ import 'dart:io'; + import 'package:dartz/dartz.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; - +import 'package:rythmify/core/error/failures.dart'; import 'package:rythmify/features/track_upload/domain/entities/track_draft.dart'; import 'package:rythmify/features/track_upload/domain/repositories/upload_track_repository.dart'; import 'package:rythmify/features/track_upload/domain/usecases/upload_track_usecase.dart'; -// Mock class MockUploadTrackRepository extends Mock implements UploadTrackRepository {} -// Fake for TrackDraft class FakeTrackDraft extends Fake implements TrackDraft {} -TrackDraft _validDraft() { +class FakeFile extends Fake implements File {} + +class FakeProgressCallback extends Fake { + void call(double progress) {} +} + +TrackDraft _draft({ + String? title = 'Song', + String? artist = 'Artist', + String? genre = 'Pop', + String localAudioPath = '/music/song.mp3', + Duration duration = const Duration(seconds: 180), + String? artworkPath, +}) { return TrackDraft( - artistId: 'user_001', - localAudioPath: '/fake/audio.mp3', - duration: const Duration(seconds: 180), - title: 'Test', - artist: 'Test Artist', - genre: 'Pop', + artistId: 'user-1', + localAudioPath: localAudioPath, + duration: duration, + title: title, + artist: artist, + genre: genre, + localArtworkPath: artworkPath, ); } void main() { - late MockUploadTrackRepository mockRepository; + late MockUploadTrackRepository repository; late UploadTrackUseCase useCase; setUpAll(() { - // 🔥 REQUIRED FIX registerFallbackValue(FakeTrackDraft()); + registerFallbackValue(FakeFile()); + registerFallbackValue(FakeProgressCallback().call); }); setUp(() { - mockRepository = MockUploadTrackRepository(); - useCase = UploadTrackUseCase(mockRepository); - registerFallbackValue(File('dummy')); //added + repository = MockUploadTrackRepository(); + useCase = UploadTrackUseCase(repository, fileExists: (_) => true); }); - // ── Validation test ───────────────────────────── - test('returns failure when title empty', () async { - final draft = TrackDraft( - artistId: 'user_001', - localAudioPath: '/fake/audio.mp3', - duration: const Duration(seconds: 180), - title: '', - artist: 'Artist', - genre: 'Pop', + group('UploadTrackUseCase validation', () { + test( + 'rejects missing title, artist, genre, audio path, and duration', + () async { + final cases = { + _draft(title: ''): 'Track title is required.', + _draft(artist: ' '): 'Artist name is required.', + _draft(genre: null): 'Please select a genre.', + _draft(localAudioPath: ' '): 'No audio file selected.', + _draft(duration: Duration.zero): 'Could not detect audio duration.', + }; + + for (final entry in cases.entries) { + final result = await useCase(draft: entry.key); + expect(result.fold((l) => l.message, (_) => ''), entry.value); + } + + verifyNever( + () => repository.uploadTrack( + draft: any(named: 'draft'), + audioFile: any(named: 'audioFile'), + artworkFile: any(named: 'artworkFile'), + onProgress: any(named: 'onProgress'), + ), + ); + }, ); - final result = await useCase(draft: draft); + test('rejects a missing audio file after metadata validation', () async { + final missingFileUseCase = UploadTrackUseCase( + repository, + fileExists: (_) => false, + ); + + final result = await missingFileUseCase(draft: _draft()); - expect(result.isLeft(), true); + expect(result.fold((l) => l, (_) => null), isA()); + verifyNever( + () => repository.uploadTrack( + draft: any(named: 'draft'), + audioFile: any(named: 'audioFile'), + artworkFile: any(named: 'artworkFile'), + onProgress: any(named: 'onProgress'), + ), + ); + }); }); - // ── Success test ─────────────────────────────── - test('success test', () async { - final draft = _validDraft(); + group('UploadTrackUseCase upload flow', () { + test( + 'passes audio/artwork files and progress callback to repository', + () async { + final progress = []; + when( + () => repository.uploadTrack( + draft: any(named: 'draft'), + audioFile: any(named: 'audioFile'), + artworkFile: any(named: 'artworkFile'), + onProgress: any(named: 'onProgress'), + ), + ).thenAnswer((invocation) async { + final callback = + invocation.namedArguments[#onProgress] as void Function(double)?; + callback?.call(.25); + callback?.call(1); + return const Right('track-1'); + }); + + final result = await useCase( + draft: _draft(artworkPath: '/covers/song.jpg'), + onProgress: progress.add, + ); + + expect(result, const Right('track-1')); + expect(progress, [.25, 1]); + verify( + () => repository.uploadTrack( + draft: any(named: 'draft'), + audioFile: any( + named: 'audioFile', + that: predicate((file) => file.path == '/music/song.mp3'), + ), + artworkFile: any( + named: 'artworkFile', + that: predicate((file) => file.path == '/covers/song.jpg'), + ), + onProgress: any(named: 'onProgress'), + ), + ).called(1); + }, + ); - when( - () => mockRepository.uploadTrack( - draft: any(named: 'draft'), - audioFile: any(named: 'audioFile'), - artworkFile: any(named: 'artworkFile'), - onProgress: any(named: 'onProgress'), - ), - ).thenAnswer((_) async => const Right('track_123')); + test('propagates repository upload failure', () async { + when( + () => repository.uploadTrack( + draft: any(named: 'draft'), + audioFile: any(named: 'audioFile'), + artworkFile: any(named: 'artworkFile'), + onProgress: any(named: 'onProgress'), + ), + ).thenAnswer((_) async => const Left(NetworkFailure())); - final result = await useCase(draft: draft); + final result = await useCase(draft: _draft()); - expect(result.isRight(), true); + expect(result, const Left(NetworkFailure())); + }); }); }