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())); + }); }); }