From 3b480a3c5278ce5c86d73eeea5030302307acf8a Mon Sep 17 00:00:00 2001 From: RanaElgharabawy Date: Fri, 1 May 2026 16:22:23 +0300 Subject: [PATCH 1/7] message push notification redirection --- lib/core/routing/app_router.dart | 6 +- .../presentation/pages/chat_screen.dart | 90 +++++++++++++------ .../push_notification_service.dart | 9 +- 3 files changed, 71 insertions(+), 34 deletions(-) diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index 31fb30ea..ee020a57 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -257,10 +257,8 @@ final routerProvider = Provider((ref) { orElse: () => null, ); - return ChatScreen( - conv: conv, - newParticipantId: conv == null ? chatId : null, - ); + if (conv != null) return ChatScreen(conv: conv); + return ChatScreen(convId: chatId); }, routes: [ GoRoute( diff --git a/lib/features/messaging/presentation/pages/chat_screen.dart b/lib/features/messaging/presentation/pages/chat_screen.dart index 252983cb..5c8772e7 100644 --- a/lib/features/messaging/presentation/pages/chat_screen.dart +++ b/lib/features/messaging/presentation/pages/chat_screen.dart @@ -34,12 +34,14 @@ class ChatScreen extends ConsumerStatefulWidget { final Conversation? conv; final String? newParticipantName; final String? newParticipantId; + final String? convId; const ChatScreen({ super.key, this.conv, this.newParticipantName, this.newParticipantId, + this.convId, }); @override @@ -54,6 +56,8 @@ class _ChatScreenState extends ConsumerState { late DataSourcesSockets _socket; Timer? _urlDetectionTimer; Timer? _blockPollTimer; + Conversation? _resolvedConv; + Conversation? get _effectiveConv => widget.conv ?? _resolvedConv; @override void initState() { @@ -85,24 +89,53 @@ class _ChatScreenState extends ConsumerState { } }); }); + } else if (widget.convId != null) { + Future.microtask(() async { + if (!mounted) return; + final convs = await ref.read(conversationProvider.future); + final found = convs.cast().firstWhere( + (c) => c?.conversationId == widget.convId, + orElse: () => null, + ); + if (!mounted || found == null) return; + setState(() => _resolvedConv = found); + if (!mounted) return; + _scrollController.addListener(_onScroll); + ref.invalidate(conversationProvider); + ref.invalidate(messagesNotifierProvider(found.conversationId)); + if (found.participantId.isNotEmpty) { + ref.invalidate(isBlockedProvider(found.participantId)); + ref.invalidate(isBlockedByProvider(found.participantId)); + } + _socket.joinConversation(found.conversationId); + _setupSocketListeners(found.conversationId); + _socket.setOnReconnectedToRoom(() { + if (mounted) _setupSocketListeners(found.conversationId); + }); + _blockPollTimer = Timer.periodic(const Duration(seconds: 10), (_) { + if (mounted && found.participantId.isNotEmpty) { + ref.invalidate(isBlockedByProvider(found.participantId)); + } + }); + }); } } void _onScroll() { - if (widget.conv == null) return; + if (_effectiveConv == null) return; final pos = _scrollController.position; // reversed list: maxScrollExtent = visual top (oldest messages) if (pos.pixels >= pos.maxScrollExtent - 200) { ref - .read(messagesNotifierProvider(widget.conv!.conversationId).notifier) + .read(messagesNotifierProvider(_effectiveConv!.conversationId).notifier) .loadMore(); } } void _setupSocketListeners(String conversationId) { _socket.onUserBlocked((_) { - if (mounted && widget.conv?.participantId != null) { - ref.invalidate(isBlockedByProvider(widget.conv!.participantId)); + if (mounted && _effectiveConv?.participantId != null) { + ref.invalidate(isBlockedByProvider(_effectiveConv!.participantId)); ref.invalidate(conversationProvider); } }); @@ -143,8 +176,9 @@ class _ChatScreenState extends ConsumerState { @override void dispose() { _urlDetectionTimer?.cancel(); - if (widget.conv != null) { - _socket.leaveConversation(widget.conv!.conversationId); + final conv = _effectiveConv; + if (conv != null) { + _socket.leaveConversation(conv.conversationId); } _socket.clearConversationListeners(); _scrollController.dispose(); @@ -157,17 +191,17 @@ class _ChatScreenState extends ConsumerState { Widget build(BuildContext context) { final myId = ref.watch(currentUserIdProvider); - final msgState = widget.conv != null - ? ref.watch(messagesNotifierProvider(widget.conv!.conversationId)) + final msgState = _effectiveConv != null + ? ref.watch(messagesNotifierProvider(_effectiveConv!.conversationId)) : null; - final unreadMsgProvider = widget.conv != null - ? ref.watch(unreadProvider(widget.conv!.conversationId)) + final unreadMsgProvider = _effectiveConv != null + ? ref.watch(unreadProvider(_effectiveConv!.conversationId)) : null; final bool isKeyboardOpen = MediaQuery.of(context).viewInsets.bottom > 0; - final participantId = widget.conv?.participantId ?? widget.newParticipantId; + final participantId = _effectiveConv?.participantId ?? widget.newParticipantId; final isBlockedAsync = participantId != null && participantId.isNotEmpty ? ref.watch(isBlockedProvider(participantId)) @@ -187,11 +221,11 @@ class _ChatScreenState extends ConsumerState { key: const Key('chat_screen_app_bar'), title: GestureDetector( onTap: () { - final id = widget.conv?.participantId ?? widget.newParticipantId; + final id = _effectiveConv?.participantId ?? widget.newParticipantId; if (id != null) context.push('/home/profile/$id'); }, child: Text( - widget.conv?.participantName ?? widget.newParticipantName ?? 'Chat', + _effectiveConv?.participantName ?? widget.newParticipantName ?? 'Chat', key: const Key('chat_participant_name_text'), ), ), @@ -207,9 +241,9 @@ class _ChatScreenState extends ConsumerState { builder: (_) => PopUpMenuWidget( participantId: participantId, parentContext: context, - onBlocked: widget.conv != null + onBlocked: _effectiveConv != null ? () => _socket.leaveConversation( - widget.conv!.conversationId, + _effectiveConv!.conversationId, ) : null, ), @@ -220,7 +254,9 @@ class _ChatScreenState extends ConsumerState { ), ], ), - body: widget.conv == null + body: widget.convId != null && _resolvedConv == null + ? const Center(child: CircularProgressIndicator()) + : _effectiveConv == null ? _blanckChatPage(isBlocked: isBlocked, isBlockedBy: isBlockedBy) : msgState == null ? const Center(child: CircularProgressIndicator()) @@ -243,11 +279,11 @@ class _ChatScreenState extends ConsumerState { ), TextButton( onPressed: () { - if (mounted && widget.conv != null) { + if (mounted && _effectiveConv != null) { ref .read( messagesNotifierProvider( - widget.conv!.conversationId, + _effectiveConv!.conversationId, ).notifier, ) .refresh(); @@ -268,7 +304,7 @@ class _ChatScreenState extends ConsumerState { Future.microtask(() async { if (!mounted) return; final latestUnreads = await ref.read( - unreadProvider(widget.conv!.conversationId).future, + unreadProvider(_effectiveConv!.conversationId).future, ); for (final unread in latestUnreads) { if (!mounted) return; @@ -335,7 +371,7 @@ class _ChatScreenState extends ConsumerState { myId: myId, senderId: message.senderId, userAvatar: index == group.length - 1 - ? widget.conv?.participantAvatar + ? _effectiveConv?.participantAvatar : null, showAvatar: index == group.length - 1, body: message.body, @@ -369,13 +405,13 @@ class _ChatScreenState extends ConsumerState { ? BlockedUserWidget( key: const Key('chat_screen_blocked_user_widget'), participantId: participantId, - onUnblocked: widget.conv != null + onUnblocked: _effectiveConv != null ? () { _socket.joinConversation( - widget.conv!.conversationId, + _effectiveConv!.conversationId, ); _setupSocketListeners( - widget.conv!.conversationId, + _effectiveConv!.conversationId, ); } : null, @@ -419,10 +455,10 @@ class _ChatScreenState extends ConsumerState { 'chat_screen_add_embed_button', ), onPressed: () async { - if (widget.conv == null) return; + if (_effectiveConv == null) return; final embeds = await context .push>( - '/home/inbox/chat/${widget.conv!.conversationId}/likes-playlists', + '/home/inbox/chat/${_effectiveConv!.conversationId}/likes-playlists', extra: List.from( _selectedEmbeds, ), @@ -952,7 +988,7 @@ class _ChatScreenState extends ConsumerState { try { if (_selectedEmbeds.isEmpty && controller.text.trim().isEmpty) return; await _processAndSendMessages( - widget.conv!.conversationId, + _effectiveConv!.conversationId, controller.text, ); if (mounted) { @@ -962,7 +998,7 @@ class _ChatScreenState extends ConsumerState { } on DioException catch (e) { if (!mounted) return; if (e.response?.statusCode == 403) { - final participantId = widget.conv?.participantId; + final participantId = _effectiveConv?.participantId; if (participantId != null && participantId.isNotEmpty) { ref.invalidate(isBlockedByProvider(participantId)); } diff --git a/lib/features/notifications/data/datasources/push_notification_service.dart b/lib/features/notifications/data/datasources/push_notification_service.dart index def9f833..baf18b1a 100644 --- a/lib/features/notifications/data/datasources/push_notification_service.dart +++ b/lib/features/notifications/data/datasources/push_notification_service.dart @@ -140,7 +140,10 @@ class PushNotificationService { final type = data['type'] as String?; if (type == null) return null; final resourceType = data['resource_type'] as String? ?? ''; - final resourceId = data['resource_id'] as String? ?? ''; + final resourceId = + (data['resource_id'] ?? data['referenceId'] ?? data['conversationId']) + as String? ?? + ''; return '$type:$resourceType:$resourceId'; } @@ -157,7 +160,7 @@ class PushNotificationService { void _navigateFromData(Map data) { _navigateByType( type: data['type'] as String?, - resourceId: (data['resource_id'] ?? data['referenceId']) as String?, + resourceId: (data['resource_id'] ?? data['referenceId'] ?? data['conversationId']) as String?, resourceType: data['resource_type'] as String?, ); } @@ -190,7 +193,7 @@ class PushNotificationService { } case 'new_post_by_followed': _route.push('/home/notifications'); - case 'message': + case 'new_message': if (hasResource) { _route.push('/home/inbox/chat/$resourceId'); } else { From 8a56da2470f2d05a0bf8f49d86c4d6399f7a2cee Mon Sep 17 00:00:00 2001 From: RanaElgharabawy Date: Fri, 1 May 2026 20:45:33 +0300 Subject: [PATCH 2/7] chat fixed from profile fixed, redirection of notifications fixed --- .../presentation/pages/chat_screen.dart | 69 ++++- .../push_notification_service.dart | 2 +- .../presentation/pages/app_icon_screen.dart | 17 ++ .../pages/inbox_settings_screen.dart | 29 +- .../buildOutputCleanup.lock | Bin 17 -> 17 bytes .../buildOutputCleanup/cache.properties | 7 +- .../settings_datasource_impl_test.dart | 239 +++++++++++++++ .../notification_preferences_model_test.dart | 270 ++++++++++++++++ .../models/privacy_settings_model_test.dart | 214 +++++++++++++ .../repositories/settings_repo_impl_test.dart | 287 ++++++++++++++++++ .../notification_preferences_entity_test.dart | 217 +++++++++++++ .../privacy_settings_entity_test.dart | 86 ++++++ .../notification_prefs_notifier_test.dart | 136 +++++++++ .../privacy_settings_notifier_test.dart | 145 +++++++++ 14 files changed, 1697 insertions(+), 21 deletions(-) create mode 100644 test/features/settings/data/datasources/settings_datasource_impl_test.dart create mode 100644 test/features/settings/data/models/notification_preferences_model_test.dart create mode 100644 test/features/settings/data/models/privacy_settings_model_test.dart create mode 100644 test/features/settings/data/repositories/settings_repo_impl_test.dart create mode 100644 test/features/settings/domain/entities/notification_preferences_entity_test.dart create mode 100644 test/features/settings/domain/entities/privacy_settings_entity_test.dart create mode 100644 test/features/settings/presentation/providers/notification_prefs_notifier_test.dart create mode 100644 test/features/settings/presentation/providers/privacy_settings_notifier_test.dart diff --git a/lib/features/messaging/presentation/pages/chat_screen.dart b/lib/features/messaging/presentation/pages/chat_screen.dart index 5c8772e7..a4b2b973 100644 --- a/lib/features/messaging/presentation/pages/chat_screen.dart +++ b/lib/features/messaging/presentation/pages/chat_screen.dart @@ -59,6 +59,9 @@ class _ChatScreenState extends ConsumerState { Conversation? _resolvedConv; Conversation? get _effectiveConv => widget.conv ?? _resolvedConv; + // false while the async conv-existence check is running, true once done. + bool _newConvCheckDone = true; + @override void initState() { super.initState(); @@ -118,6 +121,49 @@ class _ChatScreenState extends ConsumerState { } }); }); + } else if (widget.newParticipantId != null) { + _newConvCheckDone = false; + Future.microtask(() async { + if (!mounted) return; + try { + final repo = ref.read(repositoryprovider); + final convs = await repo.getConversations(); + if (!mounted) return; + final found = convs.cast().firstWhere( + (c) => c?.participantId == widget.newParticipantId, + orElse: () => null, + ); + if (!mounted) return; + if (found == null) { + setState(() => _newConvCheckDone = true); + return; + } + // Existing conv found — set up exactly like the convId branch. + setState(() { + _resolvedConv = found; + _newConvCheckDone = true; + }); + if (!mounted) return; + _scrollController.addListener(_onScroll); + ref.invalidate(messagesNotifierProvider(found.conversationId)); + if (found.participantId.isNotEmpty) { + ref.invalidate(isBlockedProvider(found.participantId)); + ref.invalidate(isBlockedByProvider(found.participantId)); + } + _socket.joinConversation(found.conversationId); + _setupSocketListeners(found.conversationId); + _socket.setOnReconnectedToRoom(() { + if (mounted) _setupSocketListeners(found.conversationId); + }); + _blockPollTimer = Timer.periodic(const Duration(seconds: 10), (_) { + if (mounted && found.participantId.isNotEmpty) { + ref.invalidate(isBlockedByProvider(found.participantId)); + } + }); + } catch (_) { + if (mounted) setState(() => _newConvCheckDone = true); + } + }); } } @@ -254,7 +300,7 @@ class _ChatScreenState extends ConsumerState { ), ], ), - body: widget.convId != null && _resolvedConv == null + body: (widget.convId != null && _resolvedConv == null) || !_newConvCheckDone ? const Center(child: CircularProgressIndicator()) : _effectiveConv == null ? _blanckChatPage(isBlocked: isBlocked, isBlockedBy: isBlockedBy) @@ -586,6 +632,16 @@ class _ChatScreenState extends ConsumerState { if (value.text.isEmpty) { return const SizedBox.shrink(); } + if (!_newConvCheckDone) { + return const Padding( + padding: EdgeInsets.all(8.0), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + ); + } return IconButton( key: const Key('chat_screen_send_button'), onPressed: () async { @@ -1026,6 +1082,17 @@ class _ChatScreenState extends ConsumerState { try { if (_selectedEmbeds.isEmpty && controller.text.trim().isEmpty) return; + // If an existing conversation was found during the pre-check, send there. + if (_resolvedConv != null) { + await _processAndSendMessages(_resolvedConv!.conversationId, controller.text); + if (!mounted) return; + ref.invalidate(conversationProvider); + controller.clear(); + setState(() => _selectedEmbeds.clear()); + context.go('/home/inbox/chat/${_resolvedConv!.conversationId}', extra: _resolvedConv); + return; + } + final newConv = await _processAndSendMessages( null, controller.text, diff --git a/lib/features/notifications/data/datasources/push_notification_service.dart b/lib/features/notifications/data/datasources/push_notification_service.dart index baf18b1a..c4245da0 100644 --- a/lib/features/notifications/data/datasources/push_notification_service.dart +++ b/lib/features/notifications/data/datasources/push_notification_service.dart @@ -192,7 +192,7 @@ class PushNotificationService { _route.push('/home/notifications'); } case 'new_post_by_followed': - _route.push('/home/notifications'); + _route.push('/home/behind-the-track/$resourceId'); case 'new_message': if (hasResource) { _route.push('/home/inbox/chat/$resourceId'); diff --git a/lib/features/settings/presentation/pages/app_icon_screen.dart b/lib/features/settings/presentation/pages/app_icon_screen.dart index 6ef650df..f91b5d52 100644 --- a/lib/features/settings/presentation/pages/app_icon_screen.dart +++ b/lib/features/settings/presentation/pages/app_icon_screen.dart @@ -1,4 +1,5 @@ import 'package:dynamic_app_icon_flutter_plus/dynamic_app_icon_flutter_plus.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -87,6 +88,11 @@ class _AppIconScreenState extends ConsumerState { void initState() { super.initState(); _loadSelected(); + // Force-refresh subscription every time this screen opens — the provider + // may have been initialized before the user subscribed and never re-fetched. + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(premiumProvider.notifier).loadMySubscription(); + }); } Future _loadSelected() async { @@ -137,6 +143,17 @@ class _AppIconScreenState extends ConsumerState { ); } + final sub = premiumState.subscription; + debugPrint('🔑 [AppIcon] isInitialized=${premiumState.isInitialized}'); + debugPrint('🔑 [AppIcon] isPremium=${premiumState.isPremium}'); + debugPrint('🔑 [AppIcon] error=${premiumState.error ?? 'none'}'); + debugPrint('🔑 [AppIcon] subscription=${sub == null ? 'NULL (no subscription)' : 'present'}'); + if (sub != null) { + debugPrint('🔑 [AppIcon] status="${sub.status}" isActive=${sub.isActive}'); + debugPrint('🔑 [AppIcon] plan.name="${sub.plan.name}" plan.isPremium=${sub.plan.isPremium}'); + debugPrint('🔑 [AppIcon] autoRenew=${sub.autoRenew} endDate=${sub.endDate}'); + } + final userIsPremium = premiumState.isPremium; return Theme( diff --git a/lib/features/settings/presentation/pages/inbox_settings_screen.dart b/lib/features/settings/presentation/pages/inbox_settings_screen.dart index 33e15ca1..29c19097 100644 --- a/lib/features/settings/presentation/pages/inbox_settings_screen.dart +++ b/lib/features/settings/presentation/pages/inbox_settings_screen.dart @@ -1,18 +1,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:rythmify/features/settings/presentation/providers/privacy_settings_notifier.dart'; +import 'package:rythmify/features/settings/domain/entities/notification_preferences_entity.dart'; +import 'package:rythmify/features/settings/presentation/providers/notification_prefs_notifier.dart'; import 'package:rythmify/features/settings/presentation/widgets/switch_tile_widget.dart'; import 'package:rythmify/features/settings/presentation/widgets/settings_options_tile_widget.dart'; -/// Controls inbox message permissions via a switch tile. -/// Contains a link to [NotificationsSettingsScreen] for notification preferences. class InboxSettingsScreen extends ConsumerWidget { const InboxSettingsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final privacyAsync = ref.watch(privacySettingsProvider); + final prefsAsync = ref.watch(notificationPrefsProvider); return Theme( data: Theme.of(context).copyWith( splashColor: const Color(0xFF3A3A3A), @@ -20,7 +19,7 @@ class InboxSettingsScreen extends ConsumerWidget { ), child: Scaffold( appBar: AppBar(title: const Text('Inbox Settings'), centerTitle: false), - body: privacyAsync.when( + body: prefsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center( child: Text( @@ -28,24 +27,28 @@ class InboxSettingsScreen extends ConsumerWidget { style: const TextStyle(color: Colors.white), ), ), - data: (data) => Column( + data: (prefs) => Column( children: [ SwitchTileWidget( - key: Key('inbox_tile'), + key: const Key('inbox_tile'), title: 'Receive messages from anyone', subtitle: - 'If you turn this setting off, only people you follow will be able to send you messages', - initSwitchValue: data.receiveMessageFromAnyone, + 'If turned off, only people you follow can send you messages', + initSwitchValue: prefs.messagesFrom == MessagesFrom.everyone, onSwitchChanged: (value) { - ref - .read(privacySettingsProvider.notifier) - .save(data.copyWith(receiveMessageFromAnyone: value)); + ref.read(notificationPrefsProvider.notifier).save( + prefs.copyWith( + messagesFrom: value + ? MessagesFrom.everyone + : MessagesFrom.followersOnly, + ), + ); }, onTap: () {}, ), const SizedBox(height: 24), SettingsOptionsTileWidget( - key: Key('inbox_notification_settings_tile'), + key: const Key('inbox_notification_settings_tile'), title: 'Notification settings', onTap: () { context.push('/library/settings/notifications'); diff --git a/packages/dynamic_app_icon_flutter_plus/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/packages/dynamic_app_icon_flutter_plus/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 45f43375f060f8df78dca7cbb8dc1ca75fc23eb6..ca6013359409428d8e80d94037a2663087bd0a52 100644 GIT binary patch literal 17 UcmZQ>;8;8>>>>>> origin/Development diff --git a/test/features/settings/data/datasources/settings_datasource_impl_test.dart b/test/features/settings/data/datasources/settings_datasource_impl_test.dart new file mode 100644 index 00000000..f5598f16 --- /dev/null +++ b/test/features/settings/data/datasources/settings_datasource_impl_test.dart @@ -0,0 +1,239 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/core/network/api_client.dart'; +import 'package:rythmify/features/settings/data/datasources/settings_datasources_impl.dart'; +import 'package:rythmify/features/settings/data/models/notification_preferences_model.dart'; +import 'package:rythmify/features/settings/data/models/privacy_settings_model.dart'; +import 'package:rythmify/features/settings/domain/entities/notification_preferences_entity.dart'; + +class MockApiClient extends Mock implements ApiClient {} + +class MockDio extends Mock implements Dio {} + +const tPrivacyJson = { + 'is_private': false, + 'receive_messages_from_anyone': true, + 'show_activities_in_discovery': true, + 'show_as_top_fan': true, + 'show_top_fans_on_tracks': true, +}; + +const tNotifJson = { + 'new_follower_push': true, + 'new_follower_email': true, + 'repost_of_your_post_push': true, + 'repost_of_your_post_email': true, + 'new_post_by_followed_push': true, + 'new_post_by_followed_email': true, + 'likes_and_plays_push': true, + 'likes_and_plays_email': true, + 'comment_on_post_push': true, + 'comment_on_post_email': true, + 'recommended_content_push': true, + 'recommended_content_email': true, + 'new_message_push': true, + 'new_message_email': true, + 'messages_from': 'everyone', + 'feature_updates_push': true, + 'feature_updates_email': true, + 'surveys_and_feedback_push': true, + 'surveys_and_feedback_email': true, + 'promotional_content_push': true, + 'promotional_content_email': true, + 'newsletter_email': true, +}; + +const tNotifModel = NotificationPreferencesModel( + newFollowerPush: true, + newFollowerEmail: true, + repostOfYourPostPush: true, + repostOfYourPostEmail: true, + newPostByFollowedPush: true, + newPostByFollowedEmail: true, + likesAndPlaysPush: true, + likesAndPlaysEmail: true, + commentOnPostPush: true, + commentOnPostEmail: true, + recommendedContentPush: true, + recommendedContentEmail: true, + newMessagePush: true, + newMessageEmail: true, + messagesFrom: MessagesFrom.everyone, + featureUpdatesPush: true, + featureUpdatesEmail: true, + surveysAndFeedbackPush: true, + surveysAndFeedbackEmail: true, + promotionalContentPush: true, + promotionalContentEmail: true, + newsletterEmail: true, +); + +Response _privacyResponse() => Response( + requestOptions: RequestOptions(path: '/users/me/privacy-settings'), + statusCode: 200, + data: {'data': tPrivacyJson}, +); + +Response _notifResponse() => Response( + requestOptions: RequestOptions(path: '/notifications/preferences'), + statusCode: 200, + data: {'data': tNotifJson}, +); + +void main() { + late MockApiClient mockApiClient; + late MockDio mockDio; + late SettingsDatasourcesImpl datasource; + + setUp(() { + mockApiClient = MockApiClient(); + mockDio = MockDio(); + when(() => mockApiClient.dio).thenReturn(mockDio); + datasource = SettingsDatasourcesImpl(mockApiClient); + + registerFallbackValue({}); + registerFallbackValue(RequestOptions(path: '')); + }); + + group('SettingsDatasourcesImpl', () { + group('getPrivacySettings', () { + test('performs GET /users/me/privacy-settings and parses response', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _privacyResponse()); + + final result = await datasource.getPrivacySettings(); + + expect(result, isA()); + expect(result.isPrivate, false); + expect(result.receiveMessageFromAnyone, true); + verify(() => mockDio.get('/users/me/privacy-settings')).called(1); + }); + + test('returns defaults when response data is null', () async { + when(() => mockDio.get(any())).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/users/me/privacy-settings'), + statusCode: 200, + data: {'data': null}, + ), + ); + + final result = await datasource.getPrivacySettings(); + + expect(result.isPrivate, false); + expect(result.receiveMessageFromAnyone, true); + }); + + test('returns defaults when response data is not a Map', () async { + when(() => mockDio.get(any())).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/users/me/privacy-settings'), + statusCode: 200, + data: 'invalid', + ), + ); + + final result = await datasource.getPrivacySettings(); + + expect(result.isPrivate, false); + expect(result.receiveMessageFromAnyone, true); + }); + }); + + group('patchPrivacySettings', () { + test('performs PATCH /users/me/privacy-settings with given fields', () async { + const fields = {'receive_messages_from_anyone': false}; + + when(() => mockDio.patch(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _privacyResponse()); + + final result = await datasource.patchPrivacySettings(fields); + + expect(result, isA()); + verify( + () => mockDio.patch( + '/users/me/privacy-settings', + data: fields, + ), + ).called(1); + }); + + test('returns defaults when patch response data is null', () async { + when(() => mockDio.patch(any(), data: any(named: 'data'))).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/users/me/privacy-settings'), + statusCode: 200, + data: {'data': null}, + ), + ); + + final result = await datasource.patchPrivacySettings({}); + + expect(result.isPrivate, false); + }); + + test('returns defaults when patch response is not a Map', () async { + when(() => mockDio.patch(any(), data: any(named: 'data'))).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/users/me/privacy-settings'), + statusCode: 200, + data: 42, + ), + ); + + final result = await datasource.patchPrivacySettings({}); + + expect(result.isPrivate, false); + }); + }); + + group('getNotificationPreferences', () { + test('performs GET /notifications/preferences and parses response', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _notifResponse()); + + final result = await datasource.getNotificationPreferences(); + + expect(result, isA()); + expect(result.newFollowerPush, true); + expect(result.messagesFrom, MessagesFrom.everyone); + verify(() => mockDio.get('/notifications/preferences')).called(1); + }); + }); + + group('updateNotificationPreferences', () { + test('performs PATCH /notifications/preferences with serialised model', () async { + when(() => mockDio.patch(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _notifResponse()); + + final result = await datasource.updateNotificationPreferences(tNotifModel); + + expect(result, isA()); + expect(result.messagesFrom, MessagesFrom.everyone); + + final captured = verify( + () => mockDio.patch( + '/notifications/preferences', + data: captureAny(named: 'data'), + ), + ).captured.single as Map; + expect(captured['new_follower_push'], true); + expect(captured['messages_from'], 'everyone'); + }); + }); + + group('deleteMyAccount', () { + test('performs DELETE /users/me', () async { + when(() => mockDio.delete(any())).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/users/me'), + statusCode: 204, + ), + ); + + await datasource.deleteMyAccount(); + + verify(() => mockDio.delete('/users/me')).called(1); + }); + }); + }); +} diff --git a/test/features/settings/data/models/notification_preferences_model_test.dart b/test/features/settings/data/models/notification_preferences_model_test.dart new file mode 100644 index 00000000..f27aa2cb --- /dev/null +++ b/test/features/settings/data/models/notification_preferences_model_test.dart @@ -0,0 +1,270 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/settings/data/models/notification_preferences_model.dart'; +import 'package:rythmify/features/settings/domain/entities/notification_preferences_entity.dart'; + +const tFullJson = { + 'new_follower_push': false, + 'new_follower_email': true, + 'repost_of_your_post_push': false, + 'repost_of_your_post_email': true, + 'new_post_by_followed_push': false, + 'new_post_by_followed_email': true, + 'likes_and_plays_push': false, + 'likes_and_plays_email': true, + 'comment_on_post_push': false, + 'comment_on_post_email': true, + 'recommended_content_push': false, + 'recommended_content_email': true, + 'new_message_push': false, + 'new_message_email': true, + 'messages_from': 'followers_only', + 'feature_updates_push': false, + 'feature_updates_email': true, + 'surveys_and_feedback_push': false, + 'surveys_and_feedback_email': true, + 'promotional_content_push': false, + 'promotional_content_email': true, + 'newsletter_email': false, +}; + +const tEntity = NotificationPreferencesEntity( + newFollowerPush: false, + newFollowerEmail: true, + repostOfYourPostPush: false, + repostOfYourPostEmail: true, + newPostByFollowedPush: false, + newPostByFollowedEmail: true, + likesAndPlaysPush: false, + likesAndPlaysEmail: true, + commentOnPostPush: false, + commentOnPostEmail: true, + recommendedContentPush: false, + recommendedContentEmail: true, + newMessagePush: false, + newMessageEmail: true, + messagesFrom: MessagesFrom.followersOnly, + featureUpdatesPush: false, + featureUpdatesEmail: true, + surveysAndFeedbackPush: false, + surveysAndFeedbackEmail: true, + promotionalContentPush: false, + promotionalContentEmail: true, + newsletterEmail: false, +); + +void main() { + group('NotificationPreferencesModel', () { + group('fromJson', () { + test('maps all fields from snake_case JSON', () { + final model = NotificationPreferencesModel.fromJson(tFullJson); + + expect(model.newFollowerPush, false); + expect(model.newFollowerEmail, true); + expect(model.repostOfYourPostPush, false); + expect(model.repostOfYourPostEmail, true); + expect(model.newPostByFollowedPush, false); + expect(model.newPostByFollowedEmail, true); + expect(model.likesAndPlaysPush, false); + expect(model.likesAndPlaysEmail, true); + expect(model.commentOnPostPush, false); + expect(model.commentOnPostEmail, true); + expect(model.recommendedContentPush, false); + expect(model.recommendedContentEmail, true); + expect(model.newMessagePush, false); + expect(model.newMessageEmail, true); + expect(model.messagesFrom, MessagesFrom.followersOnly); + expect(model.featureUpdatesPush, false); + expect(model.featureUpdatesEmail, true); + expect(model.surveysAndFeedbackPush, false); + expect(model.surveysAndFeedbackEmail, true); + expect(model.promotionalContentPush, false); + expect(model.promotionalContentEmail, true); + expect(model.newsletterEmail, false); + }); + + test('defaults all bool fields to true when keys are absent', () { + final model = NotificationPreferencesModel.fromJson({}); + + expect(model.newFollowerPush, true); + expect(model.newFollowerEmail, true); + expect(model.repostOfYourPostPush, true); + expect(model.repostOfYourPostEmail, true); + expect(model.newPostByFollowedPush, true); + expect(model.newPostByFollowedEmail, true); + expect(model.likesAndPlaysPush, true); + expect(model.likesAndPlaysEmail, true); + expect(model.commentOnPostPush, true); + expect(model.commentOnPostEmail, true); + expect(model.recommendedContentPush, true); + expect(model.recommendedContentEmail, true); + expect(model.newMessagePush, true); + expect(model.newMessageEmail, true); + expect(model.featureUpdatesPush, true); + expect(model.featureUpdatesEmail, true); + expect(model.surveysAndFeedbackPush, true); + expect(model.surveysAndFeedbackEmail, true); + expect(model.promotionalContentPush, true); + expect(model.promotionalContentEmail, true); + expect(model.newsletterEmail, true); + }); + + test('defaults messagesFrom to everyone when key is absent', () { + final model = NotificationPreferencesModel.fromJson({}); + expect(model.messagesFrom, MessagesFrom.everyone); + }); + + test('parses messages_from = everyone', () { + final model = NotificationPreferencesModel.fromJson( + {'messages_from': 'everyone'}, + ); + expect(model.messagesFrom, MessagesFrom.everyone); + }); + + test('parses messages_from = followers_only', () { + final model = NotificationPreferencesModel.fromJson( + {'messages_from': 'followers_only'}, + ); + expect(model.messagesFrom, MessagesFrom.followersOnly); + }); + + test('parses messages_from = nobody', () { + final model = NotificationPreferencesModel.fromJson( + {'messages_from': 'nobody'}, + ); + expect(model.messagesFrom, MessagesFrom.nobody); + }); + + test('defaults to everyone for an unrecognised messages_from string', () { + final model = NotificationPreferencesModel.fromJson( + {'messages_from': 'some_unknown_value'}, + ); + expect(model.messagesFrom, MessagesFrom.everyone); + }); + + test('defaults to everyone when messages_from is null', () { + final model = NotificationPreferencesModel.fromJson( + {'messages_from': null}, + ); + expect(model.messagesFrom, MessagesFrom.everyone); + }); + }); + + group('toJson', () { + test('serialises all 22 fields to snake_case keys', () { + final model = NotificationPreferencesModel.fromJson(tFullJson); + final json = model.toJson(); + + expect(json['new_follower_push'], false); + expect(json['new_follower_email'], true); + expect(json['repost_of_your_post_push'], false); + expect(json['repost_of_your_post_email'], true); + expect(json['new_post_by_followed_push'], false); + expect(json['new_post_by_followed_email'], true); + expect(json['likes_and_plays_push'], false); + expect(json['likes_and_plays_email'], true); + expect(json['comment_on_post_push'], false); + expect(json['comment_on_post_email'], true); + expect(json['recommended_content_push'], false); + expect(json['recommended_content_email'], true); + expect(json['new_message_push'], false); + expect(json['new_message_email'], true); + expect(json['messages_from'], 'followers_only'); + expect(json['feature_updates_push'], false); + expect(json['feature_updates_email'], true); + expect(json['surveys_and_feedback_push'], false); + expect(json['surveys_and_feedback_email'], true); + expect(json['promotional_content_push'], false); + expect(json['promotional_content_email'], true); + expect(json['newsletter_email'], false); + expect(json.length, 22); + }); + + test('serialises messages_from = everyone', () { + final model = NotificationPreferencesModel.fromJson( + {'messages_from': 'everyone'}, + ); + expect(model.toJson()['messages_from'], 'everyone'); + }); + + test('serialises messages_from = followers_only', () { + final model = NotificationPreferencesModel.fromJson( + {'messages_from': 'followers_only'}, + ); + expect(model.toJson()['messages_from'], 'followers_only'); + }); + + test('serialises messages_from = nobody', () { + final model = NotificationPreferencesModel.fromJson( + {'messages_from': 'nobody'}, + ); + expect(model.toJson()['messages_from'], 'nobody'); + }); + }); + + group('fromEntity', () { + test('creates model that equals the source entity', () { + final model = NotificationPreferencesModel.fromEntity(tEntity); + + expect(model.newFollowerPush, tEntity.newFollowerPush); + expect(model.newFollowerEmail, tEntity.newFollowerEmail); + expect(model.repostOfYourPostPush, tEntity.repostOfYourPostPush); + expect(model.repostOfYourPostEmail, tEntity.repostOfYourPostEmail); + expect(model.newPostByFollowedPush, tEntity.newPostByFollowedPush); + expect(model.newPostByFollowedEmail, tEntity.newPostByFollowedEmail); + expect(model.likesAndPlaysPush, tEntity.likesAndPlaysPush); + expect(model.likesAndPlaysEmail, tEntity.likesAndPlaysEmail); + expect(model.commentOnPostPush, tEntity.commentOnPostPush); + expect(model.commentOnPostEmail, tEntity.commentOnPostEmail); + expect(model.recommendedContentPush, tEntity.recommendedContentPush); + expect(model.recommendedContentEmail, tEntity.recommendedContentEmail); + expect(model.newMessagePush, tEntity.newMessagePush); + expect(model.newMessageEmail, tEntity.newMessageEmail); + expect(model.messagesFrom, tEntity.messagesFrom); + expect(model.featureUpdatesPush, tEntity.featureUpdatesPush); + expect(model.featureUpdatesEmail, tEntity.featureUpdatesEmail); + expect(model.surveysAndFeedbackPush, tEntity.surveysAndFeedbackPush); + expect(model.surveysAndFeedbackEmail, tEntity.surveysAndFeedbackEmail); + expect(model.promotionalContentPush, tEntity.promotionalContentPush); + expect(model.promotionalContentEmail, tEntity.promotionalContentEmail); + expect(model.newsletterEmail, tEntity.newsletterEmail); + }); + }); + + group('toDomain', () { + test('returns a NotificationPreferencesEntity with identical values', () { + final model = NotificationPreferencesModel.fromJson(tFullJson); + final entity = model.toDomain(); + + expect(entity, isA()); + expect(entity.newFollowerPush, model.newFollowerPush); + expect(entity.newFollowerEmail, model.newFollowerEmail); + expect(entity.repostOfYourPostPush, model.repostOfYourPostPush); + expect(entity.repostOfYourPostEmail, model.repostOfYourPostEmail); + expect(entity.newPostByFollowedPush, model.newPostByFollowedPush); + expect(entity.newPostByFollowedEmail, model.newPostByFollowedEmail); + expect(entity.likesAndPlaysPush, model.likesAndPlaysPush); + expect(entity.likesAndPlaysEmail, model.likesAndPlaysEmail); + expect(entity.commentOnPostPush, model.commentOnPostPush); + expect(entity.commentOnPostEmail, model.commentOnPostEmail); + expect(entity.recommendedContentPush, model.recommendedContentPush); + expect(entity.recommendedContentEmail, model.recommendedContentEmail); + expect(entity.newMessagePush, model.newMessagePush); + expect(entity.newMessageEmail, model.newMessageEmail); + expect(entity.messagesFrom, model.messagesFrom); + expect(entity.featureUpdatesPush, model.featureUpdatesPush); + expect(entity.featureUpdatesEmail, model.featureUpdatesEmail); + expect(entity.surveysAndFeedbackPush, model.surveysAndFeedbackPush); + expect(entity.surveysAndFeedbackEmail, model.surveysAndFeedbackEmail); + expect(entity.promotionalContentPush, model.promotionalContentPush); + expect(entity.promotionalContentEmail, model.promotionalContentEmail); + expect(entity.newsletterEmail, model.newsletterEmail); + }); + + test('round-trip fromEntity → toDomain preserves all values', () { + final model = NotificationPreferencesModel.fromEntity(tEntity); + final roundTripped = model.toDomain(); + expect(roundTripped, tEntity); + }); + }); + }); +} diff --git a/test/features/settings/data/models/privacy_settings_model_test.dart b/test/features/settings/data/models/privacy_settings_model_test.dart new file mode 100644 index 00000000..c52720cd --- /dev/null +++ b/test/features/settings/data/models/privacy_settings_model_test.dart @@ -0,0 +1,214 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/settings/data/models/privacy_settings_model.dart'; +import 'package:rythmify/features/settings/domain/entities/privacy_settings_entity.dart'; + +const tFullJson = { + 'is_private': true, + 'receive_messages_from_anyone': false, + 'show_activities_in_discovery': false, + 'show_as_top_fan': false, + 'show_top_fans_on_tracks': false, +}; + +const tModel = PrivacySettingsModel( + isPrivate: true, + receiveMessageFromAnyone: false, + showActivitiesInDiscovery: false, + showAsTopFan: false, + showTopFansOnTracks: false, +); + +const tEntity = PrivacySettingsEntity( + isPrivate: true, + receiveMessageFromAnyone: false, + showActivitiesInDiscovery: false, + showAsTopFan: false, + showTopFansOnTracks: false, +); + +void main() { + group('PrivacySettingsModel', () { + group('fromJson', () { + test('maps all fields from JSON correctly', () { + final model = PrivacySettingsModel.fromJson(tFullJson); + + expect(model.isPrivate, true); + expect(model.receiveMessageFromAnyone, false); + expect(model.showActivitiesInDiscovery, false); + expect(model.showAsTopFan, false); + expect(model.showTopFansOnTracks, false); + }); + + test('applies defaults when all keys are absent', () { + final model = PrivacySettingsModel.fromJson({}); + + expect(model.isPrivate, false); + expect(model.receiveMessageFromAnyone, true); + expect(model.showActivitiesInDiscovery, true); + expect(model.showAsTopFan, true); + expect(model.showTopFansOnTracks, true); + }); + + test('defaults isPrivate to false when null', () { + final model = PrivacySettingsModel.fromJson({'is_private': null}); + expect(model.isPrivate, false); + }); + + test('defaults receiveMessageFromAnyone to true when null', () { + final model = PrivacySettingsModel.fromJson( + {'receive_messages_from_anyone': null}, + ); + expect(model.receiveMessageFromAnyone, true); + }); + + test('defaults showActivitiesInDiscovery to true when null', () { + final model = PrivacySettingsModel.fromJson( + {'show_activities_in_discovery': null}, + ); + expect(model.showActivitiesInDiscovery, true); + }); + + test('defaults showAsTopFan to true when null', () { + final model = PrivacySettingsModel.fromJson({'show_as_top_fan': null}); + expect(model.showAsTopFan, true); + }); + + test('defaults showTopFansOnTracks to true when null', () { + final model = PrivacySettingsModel.fromJson( + {'show_top_fans_on_tracks': null}, + ); + expect(model.showTopFansOnTracks, true); + }); + }); + + group('toPatch', () { + const base = PrivacySettingsModel( + isPrivate: false, + receiveMessageFromAnyone: true, + showActivitiesInDiscovery: true, + showAsTopFan: true, + showTopFansOnTracks: true, + ); + + test('returns empty map when nothing changed', () { + const current = PrivacySettingsModel( + isPrivate: false, + receiveMessageFromAnyone: true, + showActivitiesInDiscovery: true, + showAsTopFan: true, + showTopFansOnTracks: true, + ); + expect(current.toPatch(base), isEmpty); + }); + + test('includes receive_messages_from_anyone when changed', () { + const current = PrivacySettingsModel( + isPrivate: false, + receiveMessageFromAnyone: false, + showActivitiesInDiscovery: true, + showAsTopFan: true, + showTopFansOnTracks: true, + ); + final patch = current.toPatch(base); + expect(patch['receive_messages_from_anyone'], false); + expect(patch.length, 1); + }); + + test('includes show_activities_in_discovery when changed', () { + const current = PrivacySettingsModel( + isPrivate: false, + receiveMessageFromAnyone: true, + showActivitiesInDiscovery: false, + showAsTopFan: true, + showTopFansOnTracks: true, + ); + final patch = current.toPatch(base); + expect(patch['show_activities_in_discovery'], false); + expect(patch.length, 1); + }); + + test('includes show_as_top_fan when changed', () { + const current = PrivacySettingsModel( + isPrivate: false, + receiveMessageFromAnyone: true, + showActivitiesInDiscovery: true, + showAsTopFan: false, + showTopFansOnTracks: true, + ); + final patch = current.toPatch(base); + expect(patch['show_as_top_fan'], false); + expect(patch.length, 1); + }); + + test('includes all three patchable fields when all changed', () { + const current = PrivacySettingsModel( + isPrivate: false, + receiveMessageFromAnyone: false, + showActivitiesInDiscovery: false, + showAsTopFan: false, + showTopFansOnTracks: true, + ); + final patch = current.toPatch(base); + expect(patch.length, 3); + expect(patch['receive_messages_from_anyone'], false); + expect(patch['show_activities_in_discovery'], false); + expect(patch['show_as_top_fan'], false); + }); + + test('never includes is_private even when changed', () { + const current = PrivacySettingsModel( + isPrivate: true, // changed + receiveMessageFromAnyone: true, + showActivitiesInDiscovery: true, + showAsTopFan: true, + showTopFansOnTracks: true, + ); + final patch = current.toPatch(base); + expect(patch.containsKey('is_private'), false); + }); + + test('never includes show_top_fans_on_tracks even when changed', () { + const current = PrivacySettingsModel( + isPrivate: false, + receiveMessageFromAnyone: true, + showActivitiesInDiscovery: true, + showAsTopFan: true, + showTopFansOnTracks: false, // changed + ); + final patch = current.toPatch(base); + expect(patch.containsKey('show_top_fans_on_tracks'), false); + }); + }); + + group('fromEntity', () { + test('creates model with same values as entity', () { + final model = PrivacySettingsModel.fromEntity(tEntity); + + expect(model.isPrivate, tEntity.isPrivate); + expect(model.receiveMessageFromAnyone, tEntity.receiveMessageFromAnyone); + expect(model.showActivitiesInDiscovery, tEntity.showActivitiesInDiscovery); + expect(model.showAsTopFan, tEntity.showAsTopFan); + expect(model.showTopFansOnTracks, tEntity.showTopFansOnTracks); + }); + }); + + group('toDomain', () { + test('returns a PrivacySettingsEntity with identical values', () { + final entity = tModel.toDomain(); + + expect(entity, isA()); + expect(entity.isPrivate, tModel.isPrivate); + expect(entity.receiveMessageFromAnyone, tModel.receiveMessageFromAnyone); + expect(entity.showActivitiesInDiscovery, tModel.showActivitiesInDiscovery); + expect(entity.showAsTopFan, tModel.showAsTopFan); + expect(entity.showTopFansOnTracks, tModel.showTopFansOnTracks); + }); + + test('round-trip fromEntity → toDomain preserves all values', () { + final model = PrivacySettingsModel.fromEntity(tEntity); + final roundTripped = model.toDomain(); + expect(roundTripped, tEntity); + }); + }); + }); +} diff --git a/test/features/settings/data/repositories/settings_repo_impl_test.dart b/test/features/settings/data/repositories/settings_repo_impl_test.dart new file mode 100644 index 00000000..f06d296b --- /dev/null +++ b/test/features/settings/data/repositories/settings_repo_impl_test.dart @@ -0,0 +1,287 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/settings/data/datasources/settings_remote_datasources.dart'; +import 'package:rythmify/features/settings/data/models/notification_preferences_model.dart'; +import 'package:rythmify/features/settings/data/models/privacy_settings_model.dart'; +import 'package:rythmify/features/settings/data/repositories/settings_repo_impl.dart'; +import 'package:rythmify/features/settings/domain/entities/notification_preferences_entity.dart'; +import 'package:rythmify/features/settings/domain/entities/privacy_settings_entity.dart'; + +class MockSettingsRemoteDatasources extends Mock + implements SettingsRemoteDatasources {} + +const tPrivacyModel = PrivacySettingsModel( + isPrivate: false, + receiveMessageFromAnyone: true, + showActivitiesInDiscovery: true, + showAsTopFan: true, + showTopFansOnTracks: true, +); + +const tNotifModel = NotificationPreferencesModel( + newFollowerPush: true, + newFollowerEmail: true, + repostOfYourPostPush: true, + repostOfYourPostEmail: true, + newPostByFollowedPush: true, + newPostByFollowedEmail: true, + likesAndPlaysPush: true, + likesAndPlaysEmail: true, + commentOnPostPush: true, + commentOnPostEmail: true, + recommendedContentPush: true, + recommendedContentEmail: true, + newMessagePush: true, + newMessageEmail: true, + messagesFrom: MessagesFrom.everyone, + featureUpdatesPush: true, + featureUpdatesEmail: true, + surveysAndFeedbackPush: true, + surveysAndFeedbackEmail: true, + promotionalContentPush: true, + promotionalContentEmail: true, + newsletterEmail: true, +); + +const tNotifEntity = NotificationPreferencesEntity( + newFollowerPush: false, + newFollowerEmail: true, + repostOfYourPostPush: false, + repostOfYourPostEmail: true, + newPostByFollowedPush: false, + newPostByFollowedEmail: true, + likesAndPlaysPush: false, + likesAndPlaysEmail: true, + commentOnPostPush: false, + commentOnPostEmail: true, + recommendedContentPush: false, + recommendedContentEmail: true, + newMessagePush: false, + newMessageEmail: true, + messagesFrom: MessagesFrom.followersOnly, + featureUpdatesPush: false, + featureUpdatesEmail: true, + surveysAndFeedbackPush: false, + surveysAndFeedbackEmail: true, + promotionalContentPush: false, + promotionalContentEmail: true, + newsletterEmail: false, +); + +void main() { + late MockSettingsRemoteDatasources mockDatasource; + late SettingsRepoImpl repo; + + setUp(() { + mockDatasource = MockSettingsRemoteDatasources(); + repo = SettingsRepoImpl(mockDatasource); + registerFallbackValue({}); + registerFallbackValue(tNotifModel); + }); + + group('SettingsRepoImpl', () { + group('getPrivacySettings', () { + test('returns PrivacySettingsEntity mapped from datasource model', () async { + when(() => mockDatasource.getPrivacySettings()) + .thenAnswer((_) async => tPrivacyModel); + + final result = await repo.getPrivacySettings(); + + expect(result, isA()); + expect(result.isPrivate, tPrivacyModel.isPrivate); + expect(result.receiveMessageFromAnyone, tPrivacyModel.receiveMessageFromAnyone); + expect(result.showActivitiesInDiscovery, tPrivacyModel.showActivitiesInDiscovery); + expect(result.showAsTopFan, tPrivacyModel.showAsTopFan); + expect(result.showTopFansOnTracks, tPrivacyModel.showTopFansOnTracks); + verify(() => mockDatasource.getPrivacySettings()).called(1); + verifyNoMoreInteractions(mockDatasource); + }); + + test('propagates exceptions from datasource', () { + when(() => mockDatasource.getPrivacySettings()) + .thenThrow(Exception('Network error')); + + expect(() => repo.getPrivacySettings(), throwsException); + }); + }); + + group('updatePrivacySettings', () { + const previous = PrivacySettingsEntity( + isPrivate: false, + receiveMessageFromAnyone: true, + showActivitiesInDiscovery: true, + showAsTopFan: true, + showTopFansOnTracks: true, + ); + + test('returns updated entity without calling datasource when nothing changed', () async { + const updated = previous; + + final result = await repo.updatePrivacySettings(previous, updated); + + expect(result, updated); + verifyNever(() => mockDatasource.patchPrivacySettings(any())); + }); + + test('calls patchPrivacySettings when receiveMessageFromAnyone changes', () async { + const updated = PrivacySettingsEntity( + isPrivate: false, + receiveMessageFromAnyone: false, + showActivitiesInDiscovery: true, + showAsTopFan: true, + showTopFansOnTracks: true, + ); + + when(() => mockDatasource.patchPrivacySettings(any())) + .thenAnswer((_) async => tPrivacyModel); + + final result = await repo.updatePrivacySettings(previous, updated); + + expect(result, isA()); + final captured = verify( + () => mockDatasource.patchPrivacySettings(captureAny()), + ).captured.single as Map; + expect(captured['receive_messages_from_anyone'], false); + expect(captured.containsKey('is_private'), false); + }); + + test('calls patchPrivacySettings when showActivitiesInDiscovery changes', () async { + const updated = PrivacySettingsEntity( + isPrivate: false, + receiveMessageFromAnyone: true, + showActivitiesInDiscovery: false, + showAsTopFan: true, + showTopFansOnTracks: true, + ); + + when(() => mockDatasource.patchPrivacySettings(any())) + .thenAnswer((_) async => tPrivacyModel); + + await repo.updatePrivacySettings(previous, updated); + + final captured = verify( + () => mockDatasource.patchPrivacySettings(captureAny()), + ).captured.single as Map; + expect(captured['show_activities_in_discovery'], false); + }); + + test('calls patchPrivacySettings when showAsTopFan changes', () async { + const updated = PrivacySettingsEntity( + isPrivate: false, + receiveMessageFromAnyone: true, + showActivitiesInDiscovery: true, + showAsTopFan: false, + showTopFansOnTracks: true, + ); + + when(() => mockDatasource.patchPrivacySettings(any())) + .thenAnswer((_) async => tPrivacyModel); + + await repo.updatePrivacySettings(previous, updated); + + final captured = verify( + () => mockDatasource.patchPrivacySettings(captureAny()), + ).captured.single as Map; + expect(captured['show_as_top_fan'], false); + }); + + test('propagates exceptions from patchPrivacySettings', () { + const updated = PrivacySettingsEntity( + isPrivate: false, + receiveMessageFromAnyone: false, + showActivitiesInDiscovery: true, + showAsTopFan: true, + showTopFansOnTracks: true, + ); + + when(() => mockDatasource.patchPrivacySettings(any())) + .thenThrow(Exception('Server error')); + + expect( + () => repo.updatePrivacySettings(previous, updated), + throwsException, + ); + }); + }); + + group('getNotificationPreferences', () { + test('returns NotificationPreferencesEntity from datasource', () async { + when(() => mockDatasource.getNotificationPreferences()) + .thenAnswer((_) async => tNotifModel); + + final result = await repo.getNotificationPreferences(); + + expect(result, isA()); + expect(result.newFollowerPush, tNotifModel.newFollowerPush); + expect(result.messagesFrom, tNotifModel.messagesFrom); + expect(result.newsletterEmail, tNotifModel.newsletterEmail); + verify(() => mockDatasource.getNotificationPreferences()).called(1); + verifyNoMoreInteractions(mockDatasource); + }); + + test('propagates exceptions from datasource', () { + when(() => mockDatasource.getNotificationPreferences()) + .thenThrow(Exception('Timeout')); + + expect(() => repo.getNotificationPreferences(), throwsException); + }); + }); + + group('updateNotificationPreferences', () { + test('calls datasource and returns mapped entity', () async { + when(() => mockDatasource.updateNotificationPreferences(any())) + .thenAnswer((_) async => tNotifModel); + + final result = await repo.updateNotificationPreferences(tNotifEntity); + + expect(result, isA()); + expect(result.newFollowerPush, tNotifModel.newFollowerPush); + expect(result.messagesFrom, tNotifModel.messagesFrom); + verify(() => mockDatasource.updateNotificationPreferences(any())).called(1); + verifyNoMoreInteractions(mockDatasource); + }); + + test('passes a model built from the given entity to datasource', () async { + when(() => mockDatasource.updateNotificationPreferences(any())) + .thenAnswer((_) async => tNotifModel); + + await repo.updateNotificationPreferences(tNotifEntity); + + final captured = verify( + () => mockDatasource.updateNotificationPreferences(captureAny()), + ).captured.single as NotificationPreferencesModel; + expect(captured.newFollowerPush, tNotifEntity.newFollowerPush); + expect(captured.messagesFrom, tNotifEntity.messagesFrom); + }); + + test('propagates exceptions from datasource', () { + when(() => mockDatasource.updateNotificationPreferences(any())) + .thenThrow(Exception('Bad request')); + + expect( + () => repo.updateNotificationPreferences(tNotifEntity), + throwsException, + ); + }); + }); + + group('deleteMyAccount', () { + test('delegates to datasource.deleteMyAccount', () async { + when(() => mockDatasource.deleteMyAccount()) + .thenAnswer((_) async {}); + + await repo.deleteMyAccount(); + + verify(() => mockDatasource.deleteMyAccount()).called(1); + verifyNoMoreInteractions(mockDatasource); + }); + + test('propagates exceptions from datasource', () { + when(() => mockDatasource.deleteMyAccount()) + .thenThrow(Exception('Forbidden')); + + expect(() => repo.deleteMyAccount(), throwsException); + }); + }); + }); +} diff --git a/test/features/settings/domain/entities/notification_preferences_entity_test.dart b/test/features/settings/domain/entities/notification_preferences_entity_test.dart new file mode 100644 index 00000000..6c9a989b --- /dev/null +++ b/test/features/settings/domain/entities/notification_preferences_entity_test.dart @@ -0,0 +1,217 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/settings/domain/entities/notification_preferences_entity.dart'; + +const tEntity = NotificationPreferencesEntity( + newFollowerPush: true, + newFollowerEmail: false, + repostOfYourPostPush: true, + repostOfYourPostEmail: false, + newPostByFollowedPush: true, + newPostByFollowedEmail: false, + likesAndPlaysPush: true, + likesAndPlaysEmail: false, + commentOnPostPush: true, + commentOnPostEmail: false, + recommendedContentPush: true, + recommendedContentEmail: false, + newMessagePush: true, + newMessageEmail: false, + messagesFrom: MessagesFrom.everyone, + featureUpdatesPush: true, + featureUpdatesEmail: false, + surveysAndFeedbackPush: true, + surveysAndFeedbackEmail: false, + promotionalContentPush: true, + promotionalContentEmail: false, + newsletterEmail: true, +); + +void main() { + group('NotificationPreferencesEntity', () { + test('supports value equality via Equatable', () { + const same = NotificationPreferencesEntity( + newFollowerPush: true, + newFollowerEmail: false, + repostOfYourPostPush: true, + repostOfYourPostEmail: false, + newPostByFollowedPush: true, + newPostByFollowedEmail: false, + likesAndPlaysPush: true, + likesAndPlaysEmail: false, + commentOnPostPush: true, + commentOnPostEmail: false, + recommendedContentPush: true, + recommendedContentEmail: false, + newMessagePush: true, + newMessageEmail: false, + messagesFrom: MessagesFrom.everyone, + featureUpdatesPush: true, + featureUpdatesEmail: false, + surveysAndFeedbackPush: true, + surveysAndFeedbackEmail: false, + promotionalContentPush: true, + promotionalContentEmail: false, + newsletterEmail: true, + ); + expect(tEntity, same); + }); + + test('is not equal when a field differs', () { + final different = tEntity.copyWith(newFollowerPush: false); + expect(tEntity, isNot(different)); + }); + + test('props contains all 22 fields', () { + expect(tEntity.props.length, 22); + }); + + group('copyWith', () { + test('returns identical entity when no overrides given', () { + expect(tEntity.copyWith(), tEntity); + }); + + test('overrides newFollowerPush only', () { + final updated = tEntity.copyWith(newFollowerPush: false); + expect(updated.newFollowerPush, false); + expect(updated.newFollowerEmail, tEntity.newFollowerEmail); + }); + + test('overrides newFollowerEmail only', () { + final updated = tEntity.copyWith(newFollowerEmail: true); + expect(updated.newFollowerEmail, true); + }); + + test('overrides repostOfYourPostPush only', () { + final updated = tEntity.copyWith(repostOfYourPostPush: false); + expect(updated.repostOfYourPostPush, false); + }); + + test('overrides repostOfYourPostEmail only', () { + final updated = tEntity.copyWith(repostOfYourPostEmail: true); + expect(updated.repostOfYourPostEmail, true); + }); + + test('overrides newPostByFollowedPush only', () { + final updated = tEntity.copyWith(newPostByFollowedPush: false); + expect(updated.newPostByFollowedPush, false); + }); + + test('overrides newPostByFollowedEmail only', () { + final updated = tEntity.copyWith(newPostByFollowedEmail: true); + expect(updated.newPostByFollowedEmail, true); + }); + + test('overrides likesAndPlaysPush only', () { + final updated = tEntity.copyWith(likesAndPlaysPush: false); + expect(updated.likesAndPlaysPush, false); + }); + + test('overrides likesAndPlaysEmail only', () { + final updated = tEntity.copyWith(likesAndPlaysEmail: true); + expect(updated.likesAndPlaysEmail, true); + }); + + test('overrides commentOnPostPush only', () { + final updated = tEntity.copyWith(commentOnPostPush: false); + expect(updated.commentOnPostPush, false); + }); + + test('overrides commentOnPostEmail only', () { + final updated = tEntity.copyWith(commentOnPostEmail: true); + expect(updated.commentOnPostEmail, true); + }); + + test('overrides recommendedContentPush only', () { + final updated = tEntity.copyWith(recommendedContentPush: false); + expect(updated.recommendedContentPush, false); + }); + + test('overrides recommendedContentEmail only', () { + final updated = tEntity.copyWith(recommendedContentEmail: true); + expect(updated.recommendedContentEmail, true); + }); + + test('overrides newMessagePush only', () { + final updated = tEntity.copyWith(newMessagePush: false); + expect(updated.newMessagePush, false); + }); + + test('overrides newMessageEmail only', () { + final updated = tEntity.copyWith(newMessageEmail: true); + expect(updated.newMessageEmail, true); + }); + + test('overrides messagesFrom to followersOnly', () { + final updated = tEntity.copyWith(messagesFrom: MessagesFrom.followersOnly); + expect(updated.messagesFrom, MessagesFrom.followersOnly); + }); + + test('overrides messagesFrom to nobody', () { + final updated = tEntity.copyWith(messagesFrom: MessagesFrom.nobody); + expect(updated.messagesFrom, MessagesFrom.nobody); + }); + + test('overrides featureUpdatesPush only', () { + final updated = tEntity.copyWith(featureUpdatesPush: false); + expect(updated.featureUpdatesPush, false); + }); + + test('overrides featureUpdatesEmail only', () { + final updated = tEntity.copyWith(featureUpdatesEmail: true); + expect(updated.featureUpdatesEmail, true); + }); + + test('overrides surveysAndFeedbackPush only', () { + final updated = tEntity.copyWith(surveysAndFeedbackPush: false); + expect(updated.surveysAndFeedbackPush, false); + }); + + test('overrides surveysAndFeedbackEmail only', () { + final updated = tEntity.copyWith(surveysAndFeedbackEmail: true); + expect(updated.surveysAndFeedbackEmail, true); + }); + + test('overrides promotionalContentPush only', () { + final updated = tEntity.copyWith(promotionalContentPush: false); + expect(updated.promotionalContentPush, false); + }); + + test('overrides promotionalContentEmail only', () { + final updated = tEntity.copyWith(promotionalContentEmail: true); + expect(updated.promotionalContentEmail, true); + }); + + test('overrides newsletterEmail only', () { + final updated = tEntity.copyWith(newsletterEmail: false); + expect(updated.newsletterEmail, false); + }); + + test('overrides multiple fields at once', () { + final updated = tEntity.copyWith( + newFollowerPush: false, + newsletterEmail: false, + messagesFrom: MessagesFrom.nobody, + ); + expect(updated.newFollowerPush, false); + expect(updated.newsletterEmail, false); + expect(updated.messagesFrom, MessagesFrom.nobody); + expect(updated.newFollowerEmail, tEntity.newFollowerEmail); + expect(updated.recommendedContentPush, tEntity.recommendedContentPush); + }); + }); + + group('MessagesFrom enum', () { + test('has exactly three values', () { + expect(MessagesFrom.values.length, 3); + }); + + test('contains everyone, followersOnly and nobody', () { + expect(MessagesFrom.values, containsAll([ + MessagesFrom.everyone, + MessagesFrom.followersOnly, + MessagesFrom.nobody, + ])); + }); + }); + }); +} diff --git a/test/features/settings/domain/entities/privacy_settings_entity_test.dart b/test/features/settings/domain/entities/privacy_settings_entity_test.dart new file mode 100644 index 00000000..a99202ce --- /dev/null +++ b/test/features/settings/domain/entities/privacy_settings_entity_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/settings/domain/entities/privacy_settings_entity.dart'; + +const tEntity = PrivacySettingsEntity( + isPrivate: false, + receiveMessageFromAnyone: true, + showActivitiesInDiscovery: true, + showAsTopFan: true, + showTopFansOnTracks: true, +); + +void main() { + group('PrivacySettingsEntity', () { + test('supports value equality via Equatable', () { + const same = PrivacySettingsEntity( + isPrivate: false, + receiveMessageFromAnyone: true, + showActivitiesInDiscovery: true, + showAsTopFan: true, + showTopFansOnTracks: true, + ); + expect(tEntity, same); + }); + + test('is not equal when a field differs', () { + final different = tEntity.copyWith(isPrivate: true); + expect(tEntity, isNot(different)); + }); + + test('props contains all 5 fields', () { + expect(tEntity.props.length, 5); + }); + + group('copyWith', () { + test('returns identical entity when no overrides given', () { + expect(tEntity.copyWith(), tEntity); + }); + + test('overrides isPrivate only', () { + final updated = tEntity.copyWith(isPrivate: true); + expect(updated.isPrivate, true); + expect(updated.receiveMessageFromAnyone, tEntity.receiveMessageFromAnyone); + expect(updated.showActivitiesInDiscovery, tEntity.showActivitiesInDiscovery); + expect(updated.showAsTopFan, tEntity.showAsTopFan); + expect(updated.showTopFansOnTracks, tEntity.showTopFansOnTracks); + }); + + test('overrides receiveMessageFromAnyone only', () { + final updated = tEntity.copyWith(receiveMessageFromAnyone: false); + expect(updated.receiveMessageFromAnyone, false); + expect(updated.isPrivate, tEntity.isPrivate); + }); + + test('overrides showActivitiesInDiscovery only', () { + final updated = tEntity.copyWith(showActivitiesInDiscovery: false); + expect(updated.showActivitiesInDiscovery, false); + expect(updated.isPrivate, tEntity.isPrivate); + }); + + test('overrides showAsTopFan only', () { + final updated = tEntity.copyWith(showAsTopFan: false); + expect(updated.showAsTopFan, false); + expect(updated.isPrivate, tEntity.isPrivate); + }); + + test('overrides showTopFansOnTracks only', () { + final updated = tEntity.copyWith(showTopFansOnTracks: false); + expect(updated.showTopFansOnTracks, false); + expect(updated.isPrivate, tEntity.isPrivate); + }); + + test('overrides multiple fields at once', () { + final updated = tEntity.copyWith( + isPrivate: true, + showAsTopFan: false, + showTopFansOnTracks: false, + ); + expect(updated.isPrivate, true); + expect(updated.showAsTopFan, false); + expect(updated.showTopFansOnTracks, false); + expect(updated.receiveMessageFromAnyone, tEntity.receiveMessageFromAnyone); + expect(updated.showActivitiesInDiscovery, tEntity.showActivitiesInDiscovery); + }); + }); + }); +} diff --git a/test/features/settings/presentation/providers/notification_prefs_notifier_test.dart b/test/features/settings/presentation/providers/notification_prefs_notifier_test.dart new file mode 100644 index 00000000..48de802e --- /dev/null +++ b/test/features/settings/presentation/providers/notification_prefs_notifier_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/settings/domain/entities/notification_preferences_entity.dart'; +import 'package:rythmify/features/settings/domain/repositories/settings_repo_interface.dart'; +import 'package:rythmify/features/settings/presentation/providers/notification_prefs_notifier.dart'; +import 'package:rythmify/features/settings/presentation/providers/settings_providers.dart'; + +class MockSettingsRepoInterface extends Mock implements SettingsRepoInterface {} + +const tNotifEntity = NotificationPreferencesEntity( + newFollowerPush: true, + newFollowerEmail: true, + repostOfYourPostPush: true, + repostOfYourPostEmail: true, + newPostByFollowedPush: true, + newPostByFollowedEmail: true, + likesAndPlaysPush: true, + likesAndPlaysEmail: true, + commentOnPostPush: true, + commentOnPostEmail: true, + recommendedContentPush: true, + recommendedContentEmail: true, + newMessagePush: true, + newMessageEmail: true, + messagesFrom: MessagesFrom.everyone, + featureUpdatesPush: true, + featureUpdatesEmail: true, + surveysAndFeedbackPush: true, + surveysAndFeedbackEmail: true, + promotionalContentPush: true, + promotionalContentEmail: true, + newsletterEmail: true, +); + +void main() { + late MockSettingsRepoInterface mockRepo; + late ProviderContainer container; + + setUp(() { + mockRepo = MockSettingsRepoInterface(); + registerFallbackValue(tNotifEntity); + container = ProviderContainer( + overrides: [ + settingsRepositoryProvider.overrideWithValue(mockRepo), + ], + ); + }); + + tearDown(() => container.dispose()); + + group('NotificationPrefsNotifier', () { + group('build', () { + test('loads preferences from repository on first read', () async { + when(() => mockRepo.getNotificationPreferences()) + .thenAnswer((_) async => tNotifEntity); + + final result = await container.read(notificationPrefsProvider.future); + + expect(result, tNotifEntity); + verify(() => mockRepo.getNotificationPreferences()).called(1); + }); + + test('calls getNotificationPreferences exactly once on build', () async { + when(() => mockRepo.getNotificationPreferences()) + .thenAnswer((_) async => tNotifEntity); + + await container.read(notificationPrefsProvider.future); + + verify(() => mockRepo.getNotificationPreferences()).called(1); + }); + }); + + group('save', () { + test('optimistically applies updated entity then commits server response', () async { + when(() => mockRepo.getNotificationPreferences()) + .thenAnswer((_) async => tNotifEntity); + + final updated = tNotifEntity.copyWith(newFollowerPush: false); + final serverResponse = tNotifEntity.copyWith( + newFollowerPush: false, + newsletterEmail: false, + ); + + when(() => mockRepo.updateNotificationPreferences(any())) + .thenAnswer((_) async => serverResponse); + + await container.read(notificationPrefsProvider.future); + + await container.read(notificationPrefsProvider.notifier).save(updated); + + final state = container.read(notificationPrefsProvider); + expect(state.value, serverResponse); + verify(() => mockRepo.updateNotificationPreferences(any())).called(1); + }); + + test('reverts to previous state and emits AsyncError when update fails', () async { + when(() => mockRepo.getNotificationPreferences()) + .thenAnswer((_) async => tNotifEntity); + + when(() => mockRepo.updateNotificationPreferences(any())) + .thenThrow(Exception('Update failed')); + + await container.read(notificationPrefsProvider.future); + + final updated = tNotifEntity.copyWith(newFollowerPush: false); + await container.read(notificationPrefsProvider.notifier).save(updated); + + final state = container.read(notificationPrefsProvider); + expect(state, isA()); + }); + + test('passes the updated entity to the repository', () async { + when(() => mockRepo.getNotificationPreferences()) + .thenAnswer((_) async => tNotifEntity); + + final updated = tNotifEntity.copyWith( + messagesFrom: MessagesFrom.nobody, + newFollowerPush: false, + ); + + when(() => mockRepo.updateNotificationPreferences(any())) + .thenAnswer((_) async => updated); + + await container.read(notificationPrefsProvider.future); + await container.read(notificationPrefsProvider.notifier).save(updated); + + final captured = verify( + () => mockRepo.updateNotificationPreferences(captureAny()), + ).captured.single as NotificationPreferencesEntity; + expect(captured.messagesFrom, MessagesFrom.nobody); + expect(captured.newFollowerPush, false); + }); + }); + }); +} diff --git a/test/features/settings/presentation/providers/privacy_settings_notifier_test.dart b/test/features/settings/presentation/providers/privacy_settings_notifier_test.dart new file mode 100644 index 00000000..c17255ff --- /dev/null +++ b/test/features/settings/presentation/providers/privacy_settings_notifier_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/settings/domain/entities/privacy_settings_entity.dart'; +import 'package:rythmify/features/settings/domain/repositories/settings_repo_interface.dart'; +import 'package:rythmify/features/settings/presentation/providers/privacy_settings_notifier.dart'; +import 'package:rythmify/features/settings/presentation/providers/settings_providers.dart'; + +class MockSettingsRepoInterface extends Mock implements SettingsRepoInterface {} + +const tPrivacyEntity = PrivacySettingsEntity( + isPrivate: false, + receiveMessageFromAnyone: true, + showActivitiesInDiscovery: true, + showAsTopFan: true, + showTopFansOnTracks: true, +); + +void main() { + late MockSettingsRepoInterface mockRepo; + late ProviderContainer container; + + setUp(() { + mockRepo = MockSettingsRepoInterface(); + registerFallbackValue(tPrivacyEntity); + container = ProviderContainer( + overrides: [ + settingsRepositoryProvider.overrideWithValue(mockRepo), + ], + ); + }); + + tearDown(() => container.dispose()); + + group('PrivacySettingsNotifier', () { + group('build', () { + test('loads privacy settings from repository on first read', () async { + when(() => mockRepo.getPrivacySettings()) + .thenAnswer((_) async => tPrivacyEntity); + + final result = await container.read(privacySettingsProvider.future); + + expect(result, tPrivacyEntity); + verify(() => mockRepo.getPrivacySettings()).called(1); + }); + + test('calls getPrivacySettings exactly once on build', () async { + when(() => mockRepo.getPrivacySettings()) + .thenAnswer((_) async => tPrivacyEntity); + + await container.read(privacySettingsProvider.future); + + verify(() => mockRepo.getPrivacySettings()).called(1); + }); + }); + + group('save', () { + test('optimistically applies updated entity then commits server response', () async { + when(() => mockRepo.getPrivacySettings()) + .thenAnswer((_) async => tPrivacyEntity); + + final updated = tPrivacyEntity.copyWith(receiveMessageFromAnyone: false); + const serverResponse = PrivacySettingsEntity( + isPrivate: false, + receiveMessageFromAnyone: false, + showActivitiesInDiscovery: true, + showAsTopFan: true, + showTopFansOnTracks: true, + ); + + when(() => mockRepo.updatePrivacySettings(any(), any())) + .thenAnswer((_) async => serverResponse); + + await container.read(privacySettingsProvider.future); + await container.read(privacySettingsProvider.notifier).save(updated); + + final state = container.read(privacySettingsProvider); + expect(state.value, serverResponse); + verify(() => mockRepo.updatePrivacySettings(any(), any())).called(1); + }); + + test('keeps optimistic state when update throws (silent catch)', () async { + when(() => mockRepo.getPrivacySettings()) + .thenAnswer((_) async => tPrivacyEntity); + + when(() => mockRepo.updatePrivacySettings(any(), any())) + .thenThrow(Exception('Update failed')); + + await container.read(privacySettingsProvider.future); + + final updated = tPrivacyEntity.copyWith(receiveMessageFromAnyone: false); + await container.read(privacySettingsProvider.notifier).save(updated); + + // Error is swallowed — state stays as the optimistic update. + final state = container.read(privacySettingsProvider); + expect(state, isA>()); + expect(state.value, updated); + }); + + test('passes previous state as "old" and updated entity to repository', () async { + when(() => mockRepo.getPrivacySettings()) + .thenAnswer((_) async => tPrivacyEntity); + + final updated = tPrivacyEntity.copyWith(showAsTopFan: false); + + when(() => mockRepo.updatePrivacySettings(tPrivacyEntity, updated)) + .thenAnswer((_) async => updated); + + await container.read(privacySettingsProvider.future); + await container.read(privacySettingsProvider.notifier).save(updated); + + verify( + () => mockRepo.updatePrivacySettings(tPrivacyEntity, updated), + ).called(1); + }); + + test('uses updated entity as "old" when state has no data before save', () async { + // build returns normally so we get data first, then simulate a save + // while the state is already updated (second save scenario). + when(() => mockRepo.getPrivacySettings()) + .thenAnswer((_) async => tPrivacyEntity); + + final firstUpdate = tPrivacyEntity.copyWith(showAsTopFan: false); + final secondUpdate = firstUpdate.copyWith(showActivitiesInDiscovery: false); + + when(() => mockRepo.updatePrivacySettings(any(), any())) + .thenAnswer((_) async => firstUpdate); + + await container.read(privacySettingsProvider.future); + + // First save — state becomes firstUpdate after commit. + await container.read(privacySettingsProvider.notifier).save(firstUpdate); + + when(() => mockRepo.updatePrivacySettings(any(), any())) + .thenAnswer((_) async => secondUpdate); + + // Second save — previous is firstUpdate. + await container.read(privacySettingsProvider.notifier).save(secondUpdate); + + final state = container.read(privacySettingsProvider); + expect(state.value, secondUpdate); + }); + }); + }); +} From 07e9e4fa84d166dd96ff1dc0a90a532aae35b239 Mon Sep 17 00:00:00 2001 From: RanaElgharabawy Date: Fri, 1 May 2026 21:42:50 +0300 Subject: [PATCH 3/7] unit test messaging --- linux/flutter/generated_plugins.cmake | 1 - pubspec.lock | 14 +- .../data/models/conversation_model_test.dart | 255 ++++++++++++ .../data/models/message_model_test.dart | 109 +++++ .../potential_conversation_model_test.dart | 71 ++++ .../sent_message_request_model_test.dart | 69 ++++ .../data/models/shared_embed_model_test.dart | 69 ++++ .../repository_implement_test.dart | 340 ++++++++++++++++ .../domain/entities/conversation_test.dart | 74 ++++ .../domain/entities/message_test.dart | 82 ++++ .../entities/potential_conversation_test.dart | 45 +++ .../domain/entities/shared_embed_test.dart | 48 +++ .../usecases/messaging_usecases_test.dart | 379 ++++++++++++++++++ .../block_unblock_notifier_test.dart | 199 +++++++++ .../providers/mark_as_read_notifier_test.dart | 146 +++++++ .../providers/messages_notifier_test.dart | 365 +++++++++++++++++ .../messaging_providers_wiring_test.dart | 116 ++++++ .../providers/send_message_notifier_test.dart | 264 ++++++++++++ windows/flutter/generated_plugins.cmake | 1 - 19 files changed, 2638 insertions(+), 9 deletions(-) create mode 100644 test/features/messaging/data/models/conversation_model_test.dart create mode 100644 test/features/messaging/data/models/message_model_test.dart create mode 100644 test/features/messaging/data/models/potential_conversation_model_test.dart create mode 100644 test/features/messaging/data/models/sent_message_request_model_test.dart create mode 100644 test/features/messaging/data/models/shared_embed_model_test.dart create mode 100644 test/features/messaging/data/repositories/repository_implement_test.dart create mode 100644 test/features/messaging/domain/entities/conversation_test.dart create mode 100644 test/features/messaging/domain/entities/message_test.dart create mode 100644 test/features/messaging/domain/entities/potential_conversation_test.dart create mode 100644 test/features/messaging/domain/entities/shared_embed_test.dart create mode 100644 test/features/messaging/domain/usecases/messaging_usecases_test.dart create mode 100644 test/features/messaging/presentation/providers/block_unblock_notifier_test.dart create mode 100644 test/features/messaging/presentation/providers/mark_as_read_notifier_test.dart create mode 100644 test/features/messaging/presentation/providers/messages_notifier_test.dart create mode 100644 test/features/messaging/presentation/providers/messaging_providers_wiring_test.dart create mode 100644 test/features/messaging/presentation/providers/send_message_notifier_test.dart diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index db702e70..bc7c1ec3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -12,7 +12,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/pubspec.lock b/pubspec.lock index e0b47e34..375d08ec 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1155,10 +1155,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -1816,23 +1816,23 @@ packages: dependency: transitive description: name: test - sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.29.0" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted version: "0.6.16" diff --git a/test/features/messaging/data/models/conversation_model_test.dart b/test/features/messaging/data/models/conversation_model_test.dart new file mode 100644 index 00000000..8b10d820 --- /dev/null +++ b/test/features/messaging/data/models/conversation_model_test.dart @@ -0,0 +1,255 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/messaging/data/models/conversation_model.dart'; +import 'package:rythmify/features/messaging/domain/entities/conversation.dart'; + +Map _buildJson({ + String id = 'conv-1', + Map? participant, + Map? lastMessage, + int unreadCount = 2, +}) { + return { + 'id': id, + 'participant': + participant ?? {'id': 'user-2', 'display_name': 'Alice'}, + if (lastMessage != null) 'last_message': lastMessage, + 'unread_count': unreadCount, + }; +} + +void main() { + final tDate = DateTime.parse('2024-06-01T12:00:00.000Z'); + + final tLastMessage = { + 'body': 'Hey there', + 'created_at': '2024-06-01T12:00:00.000Z', + }; + + group('ConversationModel', () { + group('fromJson', () { + test('parses standard fields correctly', () { + final model = ConversationModel.fromJson( + _buildJson( + participant: { + 'id': 'user-2', + 'display_name': 'Alice', + 'profile_picture': 'https://example.com/avatar.png', + }, + lastMessage: tLastMessage, + ), + ); + expect(model.conversationId, 'conv-1'); + expect(model.participantId, 'user-2'); + expect(model.participantName, 'Alice'); + expect(model.participantAvatar, 'https://example.com/avatar.png'); + expect(model.lastMessagePreview, 'Hey there'); + expect(model.lastMessageDate, tDate); + expect(model.unReadCount, 2); + }); + + test('reads avatar from profile_picture field', () { + final model = ConversationModel.fromJson(_buildJson( + participant: { + 'id': 'user-2', + 'display_name': 'Alice', + 'profile_picture': 'https://example.com/pp.png', + }, + lastMessage: tLastMessage, + )); + expect(model.participantAvatar, 'https://example.com/pp.png'); + }); + + test('falls back to avatar_url when profile_picture is absent', () { + final model = ConversationModel.fromJson(_buildJson( + participant: { + 'id': 'user-2', + 'display_name': 'Alice', + 'avatar_url': 'https://example.com/avatar_url.png', + }, + lastMessage: tLastMessage, + )); + expect(model.participantAvatar, 'https://example.com/avatar_url.png'); + }); + + test('falls back to avatar field when profile_picture and avatar_url absent', () { + final model = ConversationModel.fromJson(_buildJson( + participant: { + 'id': 'user-2', + 'display_name': 'Alice', + 'avatar': 'https://example.com/avatar.png', + }, + lastMessage: tLastMessage, + )); + expect(model.participantAvatar, 'https://example.com/avatar.png'); + }); + + test('participantAvatar is null when no avatar key present', () { + final model = ConversationModel.fromJson(_buildJson( + participant: {'id': 'user-2', 'display_name': 'Alice'}, + lastMessage: tLastMessage, + )); + expect(model.participantAvatar, isNull); + }); + + test('uses body as lastMessagePreview when present', () { + final model = ConversationModel.fromJson(_buildJson( + lastMessage: {'body': 'Check this out', 'created_at': '2024-06-01T12:00:00.000Z'}, + )); + expect(model.lastMessagePreview, 'Check this out'); + }); + + test('uses track URL when embed_type is track', () { + final model = ConversationModel.fromJson(_buildJson( + lastMessage: { + 'embed_id': 'track-999', + 'embed_type': 'track', + 'created_at': '2024-06-01T12:00:00.000Z', + }, + )); + expect(model.lastMessagePreview, 'https://rythmify.com/tracks/track-999'); + }); + + test('uses playlist URL when embed_type is playlist', () { + final model = ConversationModel.fromJson(_buildJson( + lastMessage: { + 'embed_id': 'pl-111', + 'embed_type': 'playlist', + 'created_at': '2024-06-01T12:00:00.000Z', + }, + )); + expect(model.lastMessagePreview, 'https://rythmify.com/playlists/pl-111'); + }); + + test('uses playlist URL when embed_type is album', () { + final model = ConversationModel.fromJson(_buildJson( + lastMessage: { + 'embed_id': 'alb-222', + 'embed_type': 'album', + 'created_at': '2024-06-01T12:00:00.000Z', + }, + )); + expect(model.lastMessagePreview, 'https://rythmify.com/playlists/alb-222'); + }); + + test('defaults lastMessagePreview to empty when no body or embed', () { + final model = ConversationModel.fromJson(_buildJson( + lastMessage: {'created_at': '2024-06-01T12:00:00.000Z'}, + )); + expect(model.lastMessagePreview, ''); + }); + + test('defaults unReadCount to 0 when missing', () { + final json = { + 'id': 'conv-2', + 'participant': {'id': 'user-2', 'display_name': 'Alice'}, + 'last_message': tLastMessage, + }; + expect(ConversationModel.fromJson(json).unReadCount, 0); + }); + + test('defaults participantName to Unknown when display_name missing', () { + final model = ConversationModel.fromJson(_buildJson( + participant: {'id': 'user-2'}, + lastMessage: tLastMessage, + )); + expect(model.participantName, 'Unknown'); + }); + + test('defaults participantId to empty string when id missing', () { + final model = ConversationModel.fromJson(_buildJson( + participant: {'display_name': 'Alice'}, + lastMessage: tLastMessage, + )); + expect(model.participantId, ''); + }); + + test('handles missing last_message gracefully', () { + final json = { + 'id': 'conv-1', + 'participant': {'id': 'user-2', 'display_name': 'Alice'}, + 'unread_count': 0, + }; + final model = ConversationModel.fromJson(json); + expect(model.lastMessagePreview, ''); + expect(model.lastMessageDate, isNotNull); + }); + + test('handles null participant gracefully', () { + final json = { + 'id': 'conv-1', + 'last_message': tLastMessage, + 'unread_count': 1, + }; + final model = ConversationModel.fromJson(json); + expect(model.participantId, ''); + expect(model.participantName, 'Unknown'); + }); + + test('uses DateTime.now() when last_message created_at is null', () { + final before = DateTime.now(); + final model = ConversationModel.fromJson(_buildJson( + lastMessage: {'body': 'Hi'}, + )); + final after = DateTime.now(); + expect( + model.lastMessageDate.millisecondsSinceEpoch, + greaterThanOrEqualTo(before.millisecondsSinceEpoch), + ); + expect( + model.lastMessageDate.millisecondsSinceEpoch, + lessThanOrEqualTo(after.millisecondsSinceEpoch), + ); + }); + }); + + group('toJson', () { + test('serializes all fields correctly', () { + final model = ConversationModel( + conversationId: 'conv-1', + participantId: 'user-2', + participantName: 'Alice', + participantAvatar: 'https://example.com/avatar.png', + lastMessagePreview: 'Hey there', + lastMessageDate: tDate, + unReadCount: 2, + ); + final json = model.toJson(); + expect(json['id'], 'conv-1'); + expect(json['unread_count'], 2); + final participant = json['participant'] as Map; + expect(participant['id'], 'user-2'); + expect(participant['display_name'], 'Alice'); + expect(participant['profile_picture'], 'https://example.com/avatar.png'); + final lastMsg = json['last_message'] as Map?; + expect(lastMsg, isNotNull); + expect(lastMsg!['body'], 'Hey there'); + expect(lastMsg['created_at'], tDate.toIso8601String()); + }); + + test('sets last_message to null when lastMessagePreview is empty', () { + final model = ConversationModel( + conversationId: 'conv-2', + participantId: 'user-3', + participantName: 'Bob', + lastMessagePreview: '', + lastMessageDate: tDate, + unReadCount: 0, + ); + final json = model.toJson(); + expect(json['last_message'], isNull); + }); + }); + + test('is a subtype of Conversation', () { + final model = ConversationModel( + conversationId: 'conv-1', + participantId: 'user-2', + participantName: 'Alice', + lastMessagePreview: 'Hi', + lastMessageDate: tDate, + unReadCount: 0, + ); + expect(model, isA()); + }); + }); +} diff --git a/test/features/messaging/data/models/message_model_test.dart b/test/features/messaging/data/models/message_model_test.dart new file mode 100644 index 00000000..04e2784c --- /dev/null +++ b/test/features/messaging/data/models/message_model_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/messaging/data/models/message_model.dart'; +import 'package:rythmify/features/messaging/domain/entities/message.dart'; + +void main() { + final tDate = DateTime.parse('2024-06-01T12:00:00.000Z'); + + final tJson = { + 'id': 'msg-1', + 'conversation_id': 'conv-1', + 'sender_id': 'user-1', + 'body': 'Hello world', + 'embed_id': null, + 'embed_type': null, + 'is_read': false, + 'created_at': '2024-06-01T12:00:00.000Z', + }; + + group('MessageModel', () { + group('fromJson', () { + test('parses all fields correctly', () { + final model = MessageModel.fromJson(tJson); + expect(model.messageId, 'msg-1'); + expect(model.conversationId, 'conv-1'); + expect(model.senderId, 'user-1'); + expect(model.body, 'Hello world'); + expect(model.embedId, isNull); + expect(model.embedType, isNull); + expect(model.isRead, false); + expect(model.createdAt, tDate); + }); + + test('parses embed fields when present', () { + final json = Map.from(tJson) + ..['embed_id'] = 'track-123' + ..['embed_type'] = 'track'; + final model = MessageModel.fromJson(json); + expect(model.embedId, 'track-123'); + expect(model.embedType, 'track'); + }); + + test('parses isRead as true', () { + final json = Map.from(tJson)..['is_read'] = true; + expect(MessageModel.fromJson(json).isRead, true); + }); + + test('parses createdAt correctly from ISO 8601 string', () { + final model = MessageModel.fromJson(tJson); + expect(model.createdAt, tDate); + }); + + test('parses null body correctly', () { + final json = Map.from(tJson)..['body'] = null; + expect(MessageModel.fromJson(json).body, isNull); + }); + }); + + group('toJson', () { + test('serializes all fields with correct keys', () { + final model = MessageModel( + messageId: 'msg-1', + conversationId: 'conv-1', + senderId: 'user-1', + body: 'Hello world', + isRead: false, + createdAt: tDate, + ); + final json = model.toJson(); + expect(json['id'], 'msg-1'); + expect(json['conversation_id'], 'conv-1'); + expect(json['sender_id'], 'user-1'); + expect(json['body'], 'Hello world'); + expect(json['embed_id'], isNull); + expect(json['embed_type'], isNull); + expect(json['is_read'], false); + expect(json['created_at'], tDate.toIso8601String()); + }); + + test('serializes embed fields when present', () { + final model = MessageModel( + messageId: 'msg-2', + conversationId: 'conv-1', + senderId: 'user-1', + embedId: 'track-456', + embedType: 'track', + isRead: true, + createdAt: tDate, + ); + final json = model.toJson(); + expect(json['embed_id'], 'track-456'); + expect(json['embed_type'], 'track'); + }); + + test('round-trips through fromJson then toJson', () { + final model = MessageModel.fromJson(tJson); + final json = model.toJson(); + expect(json['id'], tJson['id']); + expect(json['conversation_id'], tJson['conversation_id']); + expect(json['sender_id'], tJson['sender_id']); + expect(json['body'], tJson['body']); + expect(json['is_read'], tJson['is_read']); + }); + }); + + test('is a subtype of Message', () { + expect(MessageModel.fromJson(tJson), isA()); + }); + }); +} diff --git a/test/features/messaging/data/models/potential_conversation_model_test.dart b/test/features/messaging/data/models/potential_conversation_model_test.dart new file mode 100644 index 00000000..c4e7f4ba --- /dev/null +++ b/test/features/messaging/data/models/potential_conversation_model_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/messaging/data/models/potential_conversation_model.dart'; +import 'package:rythmify/features/messaging/domain/entities/potential_conversation.dart'; + +void main() { + group('PotentialConversationModel', () { + group('fromJson', () { + test('parses using id field', () { + final json = { + 'id': 'user-1', + 'display_name': 'Alice', + 'follower_count': 500, + 'profile_picture': 'https://example.com/alice.png', + }; + final model = PotentialConversationModel.fromJson(json); + expect(model.participantId, 'user-1'); + expect(model.participantName, 'Alice'); + expect(model.followersCount, 500); + expect(model.avatar, 'https://example.com/alice.png'); + expect(model.location, isNull); + }); + + test('falls back to user_id when id is null', () { + final json = { + 'user_id': 'user-2', + 'display_name': 'Bob', + }; + final model = PotentialConversationModel.fromJson(json); + expect(model.participantId, 'user-2'); + }); + + test('optional fields default to null when absent', () { + final json = { + 'id': 'user-3', + 'display_name': 'Charlie', + }; + final model = PotentialConversationModel.fromJson(json); + expect(model.followersCount, isNull); + expect(model.avatar, isNull); + expect(model.location, isNull); + }); + + test('location is always null (pending backend field)', () { + final json = { + 'id': 'user-4', + 'display_name': 'Dave', + 'location': 'Cairo', + }; + final model = PotentialConversationModel.fromJson(json); + expect(model.location, isNull); + }); + + test('parses zero follower_count', () { + final json = { + 'id': 'user-5', + 'display_name': 'Eve', + 'follower_count': 0, + }; + expect(PotentialConversationModel.fromJson(json).followersCount, 0); + }); + }); + + test('is a subtype of PotentialConversation', () { + final model = PotentialConversationModel( + participantId: 'user-1', + participantName: 'Alice', + ); + expect(model, isA()); + }); + }); +} diff --git a/test/features/messaging/data/models/sent_message_request_model_test.dart b/test/features/messaging/data/models/sent_message_request_model_test.dart new file mode 100644 index 00000000..7b9ee9fd --- /dev/null +++ b/test/features/messaging/data/models/sent_message_request_model_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/messaging/data/models/sent_message_request_model.dart'; + +void main() { + group('SentMessageRequestModel', () { + group('toJson', () { + test('returns only body when only body is set', () { + final model = SentMessageRequestModel(body: 'Hello world'); + final json = model.toJson(); + expect(json['body'], 'Hello world'); + expect(json.containsKey('resource'), false); + }); + + test('returns resource with type track when embedType is track', () { + final model = SentMessageRequestModel( + embedId: 'track-123', + embedType: 'track', + ); + final json = model.toJson(); + expect(json.containsKey('body'), false); + expect(json['resource'], {'type': 'track', 'id': 'track-123'}); + }); + + test('returns resource with type playlist when embedType is playlist', () { + final model = SentMessageRequestModel( + embedId: 'pl-456', + embedType: 'playlist', + ); + final json = model.toJson(); + expect(json['resource'], {'type': 'playlist', 'id': 'pl-456'}); + }); + + test('returns resource with type playlist when embedType is album', () { + final model = SentMessageRequestModel( + embedId: 'alb-789', + embedType: 'album', + ); + final json = model.toJson(); + expect(json['resource'], {'type': 'playlist', 'id': 'alb-789'}); + }); + + test('includes both body and resource when both are set', () { + final model = SentMessageRequestModel( + body: 'Check this out', + embedId: 'track-999', + embedType: 'track', + ); + final json = model.toJson(); + expect(json['body'], 'Check this out'); + expect(json['resource'], {'type': 'track', 'id': 'track-999'}); + }); + + test('returns empty map when all fields are null', () { + final model = SentMessageRequestModel(); + expect(model.toJson(), isEmpty); + }); + + test('does not include resource when embedId is null even if embedType is set', () { + final model = SentMessageRequestModel(embedType: 'track'); + final json = model.toJson(); + expect(json.containsKey('resource'), false); + }); + }); + + test('all fields are optional in constructor', () { + expect(() => SentMessageRequestModel(), returnsNormally); + }); + }); +} diff --git a/test/features/messaging/data/models/shared_embed_model_test.dart b/test/features/messaging/data/models/shared_embed_model_test.dart new file mode 100644 index 00000000..f6ffc116 --- /dev/null +++ b/test/features/messaging/data/models/shared_embed_model_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/messaging/data/models/shared_embed_model.dart'; +import 'package:rythmify/features/messaging/domain/entities/shared_embed.dart'; + +void main() { + group('SharedEmbedModel', () { + group('fromJson', () { + test('parses track embed correctly', () { + final json = { + 'embedId': 'track-1', + 'embedType': 'track', + 'embedName': 'Great Song', + 'artistName': 'Artist X', + 'thumbnailUrl': 'https://example.com/thumb.png', + }; + final model = SharedEmbedModel.fromJson(json); + expect(model.embedId, 'track-1'); + expect(model.embedType, 'track'); + expect(model.embedName, 'Great Song'); + expect(model.artistName, 'Artist X'); + expect(model.thumbnailUrl, 'https://example.com/thumb.png'); + }); + + test('parses playlist embed correctly', () { + final json = { + 'embedId': 'pl-2', + 'embedType': 'playlist', + 'embedName': 'Chill Vibes', + 'artistName': 'DJ Cool', + 'thumbnailUrl': 'https://example.com/pl.png', + }; + final model = SharedEmbedModel.fromJson(json); + expect(model.embedType, 'playlist'); + expect(model.embedName, 'Chill Vibes'); + }); + + test('parses album embed correctly', () { + final json = { + 'embedId': 'alb-3', + 'embedType': 'album', + 'embedName': 'Epic Album', + 'artistName': 'Band Y', + 'thumbnailUrl': 'https://example.com/alb.png', + }; + final model = SharedEmbedModel.fromJson(json); + expect(model.embedType, 'album'); + }); + }); + + test('is a subtype of SharedEmbed', () { + final model = SharedEmbedModel( + embedId: 'e1', + embedType: 'track', + embedName: 'Test Song', + ); + expect(model, isA()); + }); + + test('can be constructed directly with required fields', () { + final model = SharedEmbedModel( + embedId: 'e2', + embedType: 'playlist', + embedName: 'My Playlist', + ); + expect(model.artistName, isNull); + expect(model.thumbnailUrl, isNull); + }); + }); +} diff --git a/test/features/messaging/data/repositories/repository_implement_test.dart b/test/features/messaging/data/repositories/repository_implement_test.dart new file mode 100644 index 00000000..53e5fe09 --- /dev/null +++ b/test/features/messaging/data/repositories/repository_implement_test.dart @@ -0,0 +1,340 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/messaging/data/datasources/datasource_interface.dart'; +import 'package:rythmify/features/messaging/data/models/conversation_model.dart'; +import 'package:rythmify/features/messaging/data/models/message_model.dart'; +import 'package:rythmify/features/messaging/data/models/potential_conversation_model.dart'; +import 'package:rythmify/features/messaging/data/models/sent_message_request_model.dart'; +import 'package:rythmify/features/messaging/data/models/shared_embed_model.dart'; +import 'package:rythmify/features/messaging/data/repositories/repository_implement.dart'; + +class MockDatasourceInterface extends Mock implements DatasourceInterface {} + +void main() { + late RepositoryImplement repo; + late MockDatasourceInterface mockDatasource; + + final tDate = DateTime(2024, 6, 1, 12, 0, 0); + + final tConversationModel = ConversationModel( + conversationId: 'conv-1', + participantId: 'user-2', + participantName: 'Alice', + lastMessagePreview: 'Hey', + lastMessageDate: tDate, + unReadCount: 0, + ); + + final tMessageModel = MessageModel( + messageId: 'msg-1', + conversationId: 'conv-1', + senderId: 'user-1', + body: 'Hello', + isRead: false, + createdAt: tDate, + ); + + final tPotentialModel = PotentialConversationModel( + participantId: 'user-3', + participantName: 'Bob', + ); + + final tSharedEmbedModel = SharedEmbedModel( + embedId: 'track-1', + embedType: 'track', + embedName: 'Song', + artistName: 'Artist', + thumbnailUrl: 'https://example.com/thumb.png', + ); + + setUp(() { + mockDatasource = MockDatasourceInterface(); + repo = RepositoryImplement(dataSource: mockDatasource); + registerFallbackValue(SentMessageRequestModel()); + }); + + group('RepositoryImplement', () { + group('getConversations', () { + test('delegates to datasource and returns result', () async { + when(() => mockDatasource.getConversations()) + .thenAnswer((_) async => [tConversationModel]); + + final result = await repo.getConversations(); + + expect(result, [tConversationModel]); + verify(() => mockDatasource.getConversations()).called(1); + }); + + test('returns empty list when datasource returns empty', () async { + when(() => mockDatasource.getConversations()) + .thenAnswer((_) async => []); + + final result = await repo.getConversations(); + expect(result, isEmpty); + }); + }); + + group('getMessages', () { + test('delegates with correct conversationId and default offset', () async { + when(() => mockDatasource.getMessages( + conversationId: any(named: 'conversationId'), + offset: any(named: 'offset'), + )).thenAnswer((_) async => ([tMessageModel], 1)); + + final (messages, total) = await repo.getMessages('conv-1'); + + expect(messages, [tMessageModel]); + expect(total, 1); + verify(() => mockDatasource.getMessages( + conversationId: 'conv-1', + offset: 0, + )).called(1); + }); + + test('passes offset to datasource', () async { + when(() => mockDatasource.getMessages( + conversationId: any(named: 'conversationId'), + offset: any(named: 'offset'), + )).thenAnswer((_) async => ([tMessageModel], 50)); + + await repo.getMessages('conv-1', offset: 100); + + verify(() => mockDatasource.getMessages( + conversationId: 'conv-1', + offset: 100, + )).called(1); + }); + }); + + group('startConversation', () { + test('calls datasource.newConversation with correct args', () async { + when(() => mockDatasource.newConversation( + participantId: any(named: 'participantId'), + body: any(named: 'body'), + trackId: any(named: 'trackId'), + playlistId: any(named: 'playlistId'), + )).thenAnswer((_) async => tConversationModel); + + final result = await repo.startConversation( + 'user-2', + 'Hi', + 'track-1', + null, + ); + + expect(result, tConversationModel); + verify(() => mockDatasource.newConversation( + participantId: 'user-2', + body: 'Hi', + trackId: 'track-1', + playlistId: null, + )).called(1); + }); + + test('passes null body and embed when not provided', () async { + when(() => mockDatasource.newConversation( + participantId: any(named: 'participantId'), + body: any(named: 'body'), + trackId: any(named: 'trackId'), + playlistId: any(named: 'playlistId'), + )).thenAnswer((_) async => tConversationModel); + + await repo.startConversation('user-2', null, null, null); + + verify(() => mockDatasource.newConversation( + participantId: 'user-2', + body: null, + trackId: null, + playlistId: null, + )).called(1); + }); + }); + + group('sendMessage', () { + test('builds SentMessageRequestModel and delegates to datasource', () async { + when(() => mockDatasource.sendMessage( + conversationId: any(named: 'conversationId'), + requestContent: any(named: 'requestContent'), + )).thenAnswer((_) async => tMessageModel); + + final result = await repo.sendMessage('conv-1', 'Hello', null, null); + + expect(result, tMessageModel); + verify(() => mockDatasource.sendMessage( + conversationId: 'conv-1', + requestContent: any(named: 'requestContent'), + )).called(1); + }); + + test('passes embedId and embedType to SentMessageRequestModel', () async { + SentMessageRequestModel? captured; + when(() => mockDatasource.sendMessage( + conversationId: any(named: 'conversationId'), + requestContent: any(named: 'requestContent'), + )).thenAnswer((invocation) async { + captured = invocation.namedArguments[const Symbol('requestContent')] + as SentMessageRequestModel; + return tMessageModel; + }); + + await repo.sendMessage('conv-1', null, 'track-123', 'track'); + + expect(captured?.embedId, 'track-123'); + expect(captured?.embedType, 'track'); + expect(captured?.body, isNull); + }); + }); + + group('blockUser', () { + test('delegates userId to datasource.blockUser', () async { + when(() => mockDatasource.blockUser(userId: any(named: 'userId'))) + .thenAnswer((_) async {}); + + await repo.blockUser('user-2'); + + verify(() => mockDatasource.blockUser(userId: 'user-2')).called(1); + }); + }); + + group('unBlockUser', () { + test('delegates userId to datasource.unBlockUser', () async { + when(() => mockDatasource.unBlockUser(userId: any(named: 'userId'))) + .thenAnswer((_) async {}); + + await repo.unBlockUser('user-2'); + + verify(() => mockDatasource.unBlockUser(userId: 'user-2')).called(1); + }); + }); + + group('markMessageAsRead', () { + test('passes messageId and conversationId in correct order', () async { + when(() => mockDatasource.markMessagesAsRead( + conversationId: any(named: 'conversationId'), + messageId: any(named: 'messageId'), + )).thenAnswer((_) async {}); + + await repo.markMessageAsRead('msg-1', 'conv-1'); + + verify(() => mockDatasource.markMessagesAsRead( + conversationId: 'conv-1', + messageId: 'msg-1', + )).called(1); + }); + }); + + group('getUnReadCount', () { + test('delegates to datasource.getUnreadCount and returns value', () async { + when(() => mockDatasource.getUnreadCount()).thenAnswer((_) async => 5); + + final result = await repo.getUnReadCount(); + + expect(result, 5); + verify(() => mockDatasource.getUnreadCount()).called(1); + }); + }); + + group('getFollowings', () { + test('delegates myId to datasource and returns result', () async { + when(() => mockDatasource.getFollowings(any())) + .thenAnswer((_) async => [tPotentialModel]); + + final result = await repo.getFollowings('my-id'); + + expect(result, [tPotentialModel]); + verify(() => mockDatasource.getFollowings('my-id')).called(1); + }); + }); + + group('getSearchedUsers', () { + test('delegates query to datasource and returns result', () async { + when(() => mockDatasource.getSearchedUsers(any())) + .thenAnswer((_) async => [tPotentialModel]); + + final result = await repo.getSearchedUsers('alice'); + + expect(result, [tPotentialModel]); + verify(() => mockDatasource.getSearchedUsers('alice')).called(1); + }); + }); + + group('isBlocked', () { + test('returns true when datasource returns true', () async { + when(() => mockDatasource.isBlocked(any())) + .thenAnswer((_) async => true); + + expect(await repo.isBlocked('user-2'), true); + verify(() => mockDatasource.isBlocked('user-2')).called(1); + }); + + test('returns false when datasource returns false', () async { + when(() => mockDatasource.isBlocked(any())) + .thenAnswer((_) async => false); + + expect(await repo.isBlocked('user-3'), false); + }); + }); + + group('isBlockedBy', () { + test('returns bool from datasource', () async { + when(() => mockDatasource.isBlockedBy(any())) + .thenAnswer((_) async => true); + + expect(await repo.isBlockedBy('user-2'), true); + verify(() => mockDatasource.isBlockedBy('user-2')).called(1); + }); + }); + + group('getLikedEmbeds', () { + test('delegates to datasource.getEmbeds with userId and embedType', () async { + when(() => mockDatasource.getEmbeds(any(), any())) + .thenAnswer((_) async => [tSharedEmbedModel]); + + final result = await repo.getLikedEmbeds('user-1', 'track'); + + expect(result, [tSharedEmbedModel]); + verify(() => mockDatasource.getEmbeds('user-1', 'track')).called(1); + }); + }); + + group('getTrack', () { + test('delegates to datasource.getTrackDetails', () async { + when(() => mockDatasource.getTrackDetails(any())) + .thenAnswer((_) async => tSharedEmbedModel); + + final result = await repo.getTrack('track-1'); + + expect(result, tSharedEmbedModel); + verify(() => mockDatasource.getTrackDetails('track-1')).called(1); + }); + }); + + group('getPlaylist', () { + test('delegates to datasource.getPlaylistDetails with id and type', () async { + when(() => mockDatasource.getPlaylistDetails(any(), any())) + .thenAnswer((_) async => tSharedEmbedModel); + + final result = await repo.getPlaylist('pl-1', 'playlist'); + + expect(result, tSharedEmbedModel); + verify(() => mockDatasource.getPlaylistDetails('pl-1', 'playlist')) + .called(1); + }); + }); + + group('ensureConversation', () { + test('delegates participantId to datasource', () async { + when(() => mockDatasource.ensureConversation( + participantId: any(named: 'participantId'), + )).thenAnswer((_) async => tConversationModel); + + final result = await repo.ensureConversation('user-2'); + + expect(result, tConversationModel); + verify(() => mockDatasource.ensureConversation( + participantId: 'user-2', + )).called(1); + }); + }); + }); +} diff --git a/test/features/messaging/domain/entities/conversation_test.dart b/test/features/messaging/domain/entities/conversation_test.dart new file mode 100644 index 00000000..18409d5f --- /dev/null +++ b/test/features/messaging/domain/entities/conversation_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/messaging/domain/entities/conversation.dart'; + +void main() { + final tDate = DateTime(2024, 6, 1, 10, 30); + + final tConversation = Conversation( + conversationId: 'conv-1', + participantId: 'user-2', + participantName: 'Alice', + participantAvatar: 'https://example.com/avatar.png', + lastMessagePreview: 'Hey there', + lastMessageDate: tDate, + unReadCount: 3, + ); + + group('Conversation', () { + test('stores all fields correctly', () { + expect(tConversation.conversationId, 'conv-1'); + expect(tConversation.participantId, 'user-2'); + expect(tConversation.participantName, 'Alice'); + expect(tConversation.participantAvatar, 'https://example.com/avatar.png'); + expect(tConversation.lastMessagePreview, 'Hey there'); + expect(tConversation.lastMessageDate, tDate); + expect(tConversation.unReadCount, 3); + }); + + test('participantAvatar is null when not provided', () { + final c = Conversation( + conversationId: 'conv-2', + participantId: 'user-3', + participantName: 'Bob', + lastMessagePreview: 'Hi', + lastMessageDate: tDate, + unReadCount: 0, + ); + expect(c.participantAvatar, isNull); + }); + + test('unReadCount can be zero', () { + final c = Conversation( + conversationId: 'conv-3', + participantId: 'user-4', + participantName: 'Charlie', + lastMessagePreview: '', + lastMessageDate: tDate, + unReadCount: 0, + ); + expect(c.unReadCount, 0); + }); + + group('copyWith', () { + test('updates participantName', () { + final updated = tConversation.copyWith(participantName: 'Bob'); + expect(updated.participantName, 'Bob'); + expect(updated.conversationId, tConversation.conversationId); + expect(updated.participantId, tConversation.participantId); + expect(updated.lastMessagePreview, tConversation.lastMessagePreview); + expect(updated.lastMessageDate, tConversation.lastMessageDate); + expect(updated.unReadCount, tConversation.unReadCount); + }); + + test('preserves participantName when not provided', () { + final updated = tConversation.copyWith(); + expect(updated.participantName, 'Alice'); + }); + + test('returns a different instance', () { + final updated = tConversation.copyWith(participantName: 'Dave'); + expect(identical(updated, tConversation), false); + }); + }); + }); +} diff --git a/test/features/messaging/domain/entities/message_test.dart b/test/features/messaging/domain/entities/message_test.dart new file mode 100644 index 00000000..8d1ced68 --- /dev/null +++ b/test/features/messaging/domain/entities/message_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/messaging/domain/entities/message.dart'; + +void main() { + final tDate = DateTime(2024, 6, 1, 12, 0, 0); + + final tMessage = Message( + messageId: 'msg-1', + senderId: 'user-1', + conversationId: 'conv-1', + body: 'Hello', + isRead: false, + createdAt: tDate, + ); + + group('Message', () { + test('stores all required fields correctly', () { + expect(tMessage.messageId, 'msg-1'); + expect(tMessage.senderId, 'user-1'); + expect(tMessage.conversationId, 'conv-1'); + expect(tMessage.body, 'Hello'); + expect(tMessage.isRead, false); + expect(tMessage.createdAt, tDate); + }); + + test('optional fields default to null', () { + expect(tMessage.embedId, isNull); + expect(tMessage.embedType, isNull); + }); + + test('stores embed fields when provided', () { + final m = Message( + messageId: 'msg-2', + senderId: 'user-1', + conversationId: 'conv-1', + embedId: 'track-123', + embedType: 'track', + isRead: true, + createdAt: tDate, + ); + expect(m.embedId, 'track-123'); + expect(m.embedType, 'track'); + expect(m.body, isNull); + }); + + group('copyWith', () { + test('returns new message with updated isRead', () { + final updated = tMessage.copyWith(isRead: true); + expect(updated.isRead, true); + expect(updated.messageId, tMessage.messageId); + expect(updated.senderId, tMessage.senderId); + expect(updated.conversationId, tMessage.conversationId); + expect(updated.body, tMessage.body); + expect(updated.embedId, tMessage.embedId); + expect(updated.embedType, tMessage.embedType); + expect(updated.createdAt, tMessage.createdAt); + }); + + test('preserves isRead when not provided', () { + final updated = tMessage.copyWith(); + expect(updated.isRead, false); + }); + + test('returns a different instance', () { + final updated = tMessage.copyWith(isRead: true); + expect(identical(updated, tMessage), false); + }); + + test('can toggle isRead to false', () { + final readMsg = Message( + messageId: 'msg-3', + senderId: 'user-1', + conversationId: 'conv-1', + isRead: true, + createdAt: tDate, + ); + final updated = readMsg.copyWith(isRead: false); + expect(updated.isRead, false); + }); + }); + }); +} diff --git a/test/features/messaging/domain/entities/potential_conversation_test.dart b/test/features/messaging/domain/entities/potential_conversation_test.dart new file mode 100644 index 00000000..b5358f70 --- /dev/null +++ b/test/features/messaging/domain/entities/potential_conversation_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/messaging/domain/entities/potential_conversation.dart'; + +void main() { + group('PotentialConversation', () { + test('stores required fields', () { + final pc = PotentialConversation( + participantId: 'user-1', + participantName: 'Alice', + ); + expect(pc.participantId, 'user-1'); + expect(pc.participantName, 'Alice'); + }); + + test('optional fields default to null', () { + final pc = PotentialConversation( + participantId: 'user-1', + participantName: 'Alice', + ); + expect(pc.followersCount, isNull); + expect(pc.location, isNull); + expect(pc.avatar, isNull); + }); + + test('stores all optional fields when provided', () { + final pc = PotentialConversation( + participantId: 'user-2', + participantName: 'Bob', + followersCount: 1000, + location: 'Cairo, Egypt', + avatar: 'https://example.com/bob.png', + ); + expect(pc.followersCount, 1000); + expect(pc.location, 'Cairo, Egypt'); + expect(pc.avatar, 'https://example.com/bob.png'); + }); + + test('participantId and participantName are required', () { + expect( + () => PotentialConversation(participantId: 'x', participantName: 'y'), + returnsNormally, + ); + }); + }); +} diff --git a/test/features/messaging/domain/entities/shared_embed_test.dart b/test/features/messaging/domain/entities/shared_embed_test.dart new file mode 100644 index 00000000..62720d9a --- /dev/null +++ b/test/features/messaging/domain/entities/shared_embed_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/messaging/domain/entities/shared_embed.dart'; + +void main() { + group('SharedEmbed', () { + test('stores required fields', () { + final embed = SharedEmbed( + embedId: 'track-1', + embedType: 'track', + embedName: 'Song Title', + ); + expect(embed.embedId, 'track-1'); + expect(embed.embedType, 'track'); + expect(embed.embedName, 'Song Title'); + }); + + test('optional fields default to null', () { + final embed = SharedEmbed( + embedId: 'track-1', + embedType: 'track', + embedName: 'Song Title', + ); + expect(embed.artistName, isNull); + expect(embed.thumbnailUrl, isNull); + }); + + test('stores all optional fields when provided', () { + final embed = SharedEmbed( + embedId: 'pl-1', + embedType: 'playlist', + embedName: 'My Playlist', + artistName: 'DJ Bob', + thumbnailUrl: 'https://example.com/thumb.png', + ); + expect(embed.artistName, 'DJ Bob'); + expect(embed.thumbnailUrl, 'https://example.com/thumb.png'); + }); + + test('supports album embedType', () { + final embed = SharedEmbed( + embedId: 'alb-1', + embedType: 'album', + embedName: 'Great Album', + ); + expect(embed.embedType, 'album'); + }); + }); +} diff --git a/test/features/messaging/domain/usecases/messaging_usecases_test.dart b/test/features/messaging/domain/usecases/messaging_usecases_test.dart new file mode 100644 index 00000000..e9e48803 --- /dev/null +++ b/test/features/messaging/domain/usecases/messaging_usecases_test.dart @@ -0,0 +1,379 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/messaging/domain/entities/conversation.dart'; +import 'package:rythmify/features/messaging/domain/entities/message.dart'; +import 'package:rythmify/features/messaging/domain/entities/potential_conversation.dart'; +import 'package:rythmify/features/messaging/domain/entities/shared_embed.dart'; +import 'package:rythmify/features/messaging/domain/repositories/messaging_repository.dart'; +import 'package:rythmify/features/messaging/domain/usecases/block_user_usecase.dart'; +import 'package:rythmify/features/messaging/domain/usecases/ensure_conversation_usecase.dart'; +import 'package:rythmify/features/messaging/domain/usecases/get_conversations_usecase.dart'; +import 'package:rythmify/features/messaging/domain/usecases/get_followings_usecase.dart'; +import 'package:rythmify/features/messaging/domain/usecases/get_liked_embeds_usecase.dart'; +import 'package:rythmify/features/messaging/domain/usecases/get_messages_usecase.dart'; +import 'package:rythmify/features/messaging/domain/usecases/get_playlist_details_usecase.dart'; +import 'package:rythmify/features/messaging/domain/usecases/get_searched_users_usecase.dart'; +import 'package:rythmify/features/messaging/domain/usecases/get_track_details_usecase.dart'; +import 'package:rythmify/features/messaging/domain/usecases/get_unread_count_usecase.dart'; +import 'package:rythmify/features/messaging/domain/usecases/is_blocked_by_usecase.dart'; +import 'package:rythmify/features/messaging/domain/usecases/is_blocked_usecase.dart'; +import 'package:rythmify/features/messaging/domain/usecases/mark_messages_as_read_usecase.dart'; +import 'package:rythmify/features/messaging/domain/usecases/send_message_usecase.dart'; +import 'package:rythmify/features/messaging/domain/usecases/start_conversation_usecase.dart'; +import 'package:rythmify/features/messaging/domain/usecases/unblock_user_usecase.dart'; + +class MockMessagingRepository extends Mock implements MessagingRepository {} + +void main() { + late MockMessagingRepository mockRepo; + + final tDate = DateTime(2024, 6, 1); + + final tConversation = Conversation( + conversationId: 'conv-1', + participantId: 'user-2', + participantName: 'Alice', + lastMessagePreview: 'Hey', + lastMessageDate: tDate, + unReadCount: 0, + ); + + final tMessage = Message( + messageId: 'msg-1', + senderId: 'user-1', + conversationId: 'conv-1', + body: 'Hello', + isRead: false, + createdAt: tDate, + ); + + final tPotential = PotentialConversation( + participantId: 'user-3', + participantName: 'Bob', + ); + + final tEmbed = SharedEmbed( + embedId: 'track-1', + embedType: 'track', + embedName: 'Song', + ); + + setUp(() { + mockRepo = MockMessagingRepository(); + }); + + group('GetConversationsUsecase', () { + test('calls repo.getConversations and returns list', () async { + when(() => mockRepo.getConversations()) + .thenAnswer((_) async => [tConversation]); + + final result = await GetConversationsUsecase(repo: mockRepo)(); + + expect(result, [tConversation]); + verify(() => mockRepo.getConversations()).called(1); + }); + + test('returns empty list', () async { + when(() => mockRepo.getConversations()).thenAnswer((_) async => []); + + expect(await GetConversationsUsecase(repo: mockRepo)(), isEmpty); + }); + + test('propagates exceptions from repo', () { + when(() => mockRepo.getConversations()) + .thenThrow(Exception('Network error')); + + expect( + () => GetConversationsUsecase(repo: mockRepo)(), + throwsException, + ); + }); + }); + + group('GetMessagesUsecase', () { + test('calls repo.getMessages with conversationId and offset', () async { + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async => ([tMessage], 1)); + + final (messages, total) = + await GetMessagesUsecase(repo: mockRepo)('conv-1', offset: 10); + + expect(messages, [tMessage]); + expect(total, 1); + verify(() => mockRepo.getMessages('conv-1', offset: 10)).called(1); + }); + + test('uses default offset=0 when not specified', () async { + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async => ([tMessage], 1)); + + await GetMessagesUsecase(repo: mockRepo)('conv-1'); + + verify(() => mockRepo.getMessages('conv-1', offset: 0)).called(1); + }); + }); + + group('SendMessageUsecase', () { + test('calls repo.sendMessage with all args', () async { + when(() => mockRepo.sendMessage(any(), any(), any(), any())) + .thenAnswer((_) async => tMessage); + + final result = await SendMessageUsecase(repo: mockRepo)( + 'conv-1', + 'Hello', + null, + null, + ); + + expect(result, tMessage); + verify(() => mockRepo.sendMessage('conv-1', 'Hello', null, null)) + .called(1); + }); + + test('passes embedId and embedType through', () async { + when(() => mockRepo.sendMessage(any(), any(), any(), any())) + .thenAnswer((_) async => tMessage); + + await SendMessageUsecase(repo: mockRepo)( + 'conv-1', + null, + 'track-1', + 'track', + ); + + verify(() => mockRepo.sendMessage('conv-1', null, 'track-1', 'track')) + .called(1); + }); + }); + + group('StartConversationUsecase', () { + test('calls repo.startConversation with all args', () async { + when(() => mockRepo.startConversation(any(), any(), any(), any())) + .thenAnswer((_) async => tConversation); + + final result = await StartConversationUsecase(repo: mockRepo)( + 'user-2', + body: 'Hello', + trackId: 'track-1', + playlistId: null, + ); + + expect(result, tConversation); + verify(() => mockRepo.startConversation('user-2', 'Hello', 'track-1', null)) + .called(1); + }); + + test('passes null optional args', () async { + when(() => mockRepo.startConversation(any(), any(), any(), any())) + .thenAnswer((_) async => tConversation); + + await StartConversationUsecase(repo: mockRepo)('user-2'); + + verify(() => mockRepo.startConversation('user-2', null, null, null)) + .called(1); + }); + }); + + group('BlockUserUsecase', () { + test('calls repo.blockUser with participantId', () async { + when(() => mockRepo.blockUser(any())).thenAnswer((_) async {}); + + await BlockUserUsecase(repo: mockRepo)('user-2'); + + verify(() => mockRepo.blockUser('user-2')).called(1); + }); + + test('propagates exceptions', () { + when(() => mockRepo.blockUser(any())).thenThrow(Exception('Error')); + + expect(() => BlockUserUsecase(repo: mockRepo)('user-2'), throwsException); + }); + }); + + group('UnblockUserUsecase', () { + test('calls repo.unBlockUser with participantId', () async { + when(() => mockRepo.unBlockUser(any())).thenAnswer((_) async {}); + + await UnblockUserUsecase(repo: mockRepo)('user-2'); + + verify(() => mockRepo.unBlockUser('user-2')).called(1); + }); + }); + + group('MarkMessagesAsReadUsecase', () { + test('calls repo.markMessageAsRead with messageId then conversationId', () async { + when(() => mockRepo.markMessageAsRead(any(), any())) + .thenAnswer((_) async {}); + + await MarkMessagesAsReadUsecase(repo: mockRepo)('msg-1', 'conv-1'); + + verify(() => mockRepo.markMessageAsRead('msg-1', 'conv-1')).called(1); + }); + + test('propagates exceptions', () { + when(() => mockRepo.markMessageAsRead(any(), any())) + .thenThrow(Exception('Server error')); + + expect( + () => MarkMessagesAsReadUsecase(repo: mockRepo)('msg-1', 'conv-1'), + throwsException, + ); + }); + }); + + group('GetUnreadCountUsecase', () { + test('calls repo.getUnReadCount and returns value', () async { + when(() => mockRepo.getUnReadCount()).thenAnswer((_) async => 7); + + final result = await GetUnreadCountUsecase(repo: mockRepo)(); + + expect(result, 7); + verify(() => mockRepo.getUnReadCount()).called(1); + }); + + test('returns 0 when no unread messages', () async { + when(() => mockRepo.getUnReadCount()).thenAnswer((_) async => 0); + + expect(await GetUnreadCountUsecase(repo: mockRepo)(), 0); + }); + }); + + group('GetFollowingsUsecase', () { + test('calls repo.getFollowings with userId', () async { + when(() => mockRepo.getFollowings(any())) + .thenAnswer((_) async => [tPotential]); + + final result = await GetFollowingsUsecase(repo: mockRepo)('my-id'); + + expect(result, [tPotential]); + verify(() => mockRepo.getFollowings('my-id')).called(1); + }); + + test('returns empty list', () async { + when(() => mockRepo.getFollowings(any())).thenAnswer((_) async => []); + + expect(await GetFollowingsUsecase(repo: mockRepo)('my-id'), isEmpty); + }); + }); + + group('GetSearchedUsersUsecase', () { + test('calls repo.getSearchedUsers with query', () async { + when(() => mockRepo.getSearchedUsers(any())) + .thenAnswer((_) async => [tPotential]); + + final result = await GetSearchedUsersUsecase(repo: mockRepo)('alice'); + + expect(result, [tPotential]); + verify(() => mockRepo.getSearchedUsers('alice')).called(1); + }); + }); + + group('IsBlockedUsecase', () { + test('returns true when user is blocked', () async { + when(() => mockRepo.isBlocked(any())).thenAnswer((_) async => true); + + expect(await IsBlockedUsecase(repo: mockRepo)('user-2'), true); + verify(() => mockRepo.isBlocked('user-2')).called(1); + }); + + test('returns false when user is not blocked', () async { + when(() => mockRepo.isBlocked(any())).thenAnswer((_) async => false); + + expect(await IsBlockedUsecase(repo: mockRepo)('user-2'), false); + }); + }); + + group('IsBlockedByUsecase', () { + test('returns true when blocked by participant', () async { + when(() => mockRepo.isBlockedBy(any())).thenAnswer((_) async => true); + + expect(await IsBlockedByUsecase(repo: mockRepo)('user-2'), true); + verify(() => mockRepo.isBlockedBy('user-2')).called(1); + }); + + test('returns false when not blocked by participant', () async { + when(() => mockRepo.isBlockedBy(any())).thenAnswer((_) async => false); + + expect(await IsBlockedByUsecase(repo: mockRepo)('user-2'), false); + }); + }); + + group('GetLikedEmbedsUseCase', () { + test('calls repo.getLikedEmbeds with userId and embedType', () async { + when(() => mockRepo.getLikedEmbeds(any(), any())) + .thenAnswer((_) async => [tEmbed]); + + final result = + await GetLikedEmbedsUseCase(repo: mockRepo)('user-1', 'track'); + + expect(result, [tEmbed]); + verify(() => mockRepo.getLikedEmbeds('user-1', 'track')).called(1); + }); + + test('returns empty list when no liked embeds', () async { + when(() => mockRepo.getLikedEmbeds(any(), any())) + .thenAnswer((_) async => []); + + expect( + await GetLikedEmbedsUseCase(repo: mockRepo)('user-1', 'playlist'), + isEmpty, + ); + }); + }); + + group('GetTrackDetailsUsecase', () { + test('calls repo.getTrack and returns embed', () async { + when(() => mockRepo.getTrack(any())).thenAnswer((_) async => tEmbed); + + final result = await GetTrackDetailsUsecase(repo: mockRepo)('track-1'); + + expect(result, tEmbed); + verify(() => mockRepo.getTrack('track-1')).called(1); + }); + }); + + group('GetPlaylistDetailsUsecase', () { + test('calls repo.getPlaylist with id and type', () async { + when(() => mockRepo.getPlaylist(any(), any())) + .thenAnswer((_) async => tEmbed); + + final result = + await GetPlaylistDetailsUsecase(repo: mockRepo)('pl-1', 'playlist'); + + expect(result, tEmbed); + verify(() => mockRepo.getPlaylist('pl-1', 'playlist')).called(1); + }); + + test('passes album type through', () async { + when(() => mockRepo.getPlaylist(any(), any())) + .thenAnswer((_) async => tEmbed); + + await GetPlaylistDetailsUsecase(repo: mockRepo)('alb-1', 'album'); + + verify(() => mockRepo.getPlaylist('alb-1', 'album')).called(1); + }); + }); + + group('EnsureConversationUsecase', () { + test('calls repo.ensureConversation and returns conversation', () async { + when(() => mockRepo.ensureConversation(any())) + .thenAnswer((_) async => tConversation); + + final result = + await EnsureConversationUsecase(repo: mockRepo)('user-2'); + + expect(result, tConversation); + verify(() => mockRepo.ensureConversation('user-2')).called(1); + }); + + test('propagates exceptions', () { + when(() => mockRepo.ensureConversation(any())) + .thenThrow(Exception('Not found')); + + expect( + () => EnsureConversationUsecase(repo: mockRepo)('user-99'), + throwsException, + ); + }); + }); +} diff --git a/test/features/messaging/presentation/providers/block_unblock_notifier_test.dart b/test/features/messaging/presentation/providers/block_unblock_notifier_test.dart new file mode 100644 index 00000000..203aeac5 --- /dev/null +++ b/test/features/messaging/presentation/providers/block_unblock_notifier_test.dart @@ -0,0 +1,199 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/messaging/domain/entities/conversation.dart'; +import 'package:rythmify/features/messaging/domain/entities/message.dart'; +import 'package:rythmify/features/messaging/domain/repositories/messaging_repository.dart'; +import 'package:rythmify/features/messaging/presentation/providers/block_user_notifier.dart'; +import 'package:rythmify/features/messaging/presentation/providers/block_user_provider.dart'; +import 'package:rythmify/features/messaging/presentation/providers/repository_provider.dart'; +import 'package:rythmify/features/messaging/presentation/providers/un_block_notifier.dart'; +import 'package:rythmify/features/messaging/presentation/providers/un_block_provider.dart'; + +class MockMessagingRepository extends Mock implements MessagingRepository {} + +void main() { + late MockMessagingRepository mockRepo; + + final tDate = DateTime(2024, 6, 1); + + final tConversation = Conversation( + conversationId: 'conv-1', + participantId: 'user-2', + participantName: 'Alice', + lastMessagePreview: 'Hey', + lastMessageDate: tDate, + unReadCount: 0, + ); + + final tMessage = Message( + messageId: 'msg-1', + senderId: 'user-1', + conversationId: 'conv-1', + isRead: false, + createdAt: tDate, + ); + + setUp(() { + mockRepo = MockMessagingRepository(); + + when(() => mockRepo.getConversations()) + .thenAnswer((_) async => [tConversation]); + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async => ([tMessage], 1)); + when(() => mockRepo.isBlocked(any())).thenAnswer((_) async => false); + }); + + ProviderContainer buildContainer() { + return ProviderContainer( + overrides: [repositoryprovider.overrideWithValue(mockRepo)], + ); + } + + // ========================================================================= + // BlockUserNotifier + // ========================================================================= + + group('BlockUserNotifier', () { + test('initial state is false', () { + final container = buildContainer(); + addTearDown(container.dispose); + + expect(container.read(blockUserProvider), false); + }); + + test('blockUser calls repo.blockUser with participantId', () async { + when(() => mockRepo.blockUser(any())).thenAnswer((_) async {}); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container + .read(blockUserProvider.notifier) + .blockUser(participantId: 'user-2'); + + verify(() => mockRepo.blockUser('user-2')).called(1); + }); + + test('state returns to false after blockUser completes', () async { + when(() => mockRepo.blockUser(any())).thenAnswer((_) async {}); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container + .read(blockUserProvider.notifier) + .blockUser(participantId: 'user-2'); + + expect(container.read(blockUserProvider), false); + }); + + test('is a BlockUserNotifier', () { + final container = buildContainer(); + addTearDown(container.dispose); + + expect( + container.read(blockUserProvider.notifier), + isA(), + ); + }); + + test('blockUser propagates through usecase to repo', () async { + when(() => mockRepo.blockUser(any())).thenAnswer((_) async {}); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container + .read(blockUserProvider.notifier) + .blockUser(participantId: 'user-99'); + + verify(() => mockRepo.blockUser('user-99')).called(1); + }); + }); + + // ========================================================================= + // UnblockNotifier + // ========================================================================= + + group('UnblockNotifier', () { + test('initial state is false', () { + final container = buildContainer(); + addTearDown(container.dispose); + + expect(container.read(unBlockProvider), false); + }); + + test('unBlockUser calls repo.unBlockUser with participantId', () async { + when(() => mockRepo.unBlockUser(any())).thenAnswer((_) async {}); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container + .read(unBlockProvider.notifier) + .unBlockUser(participantId: 'user-2'); + + verify(() => mockRepo.unBlockUser('user-2')).called(1); + }); + + test('state returns to false after unBlockUser completes', () async { + when(() => mockRepo.unBlockUser(any())).thenAnswer((_) async {}); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container + .read(unBlockProvider.notifier) + .unBlockUser(participantId: 'user-2'); + + expect(container.read(unBlockProvider), false); + }); + + test('is an UnblockNotifier', () { + final container = buildContainer(); + addTearDown(container.dispose); + + expect( + container.read(unBlockProvider.notifier), + isA(), + ); + }); + + test('unBlockUser propagates through usecase to repo', () async { + when(() => mockRepo.unBlockUser(any())).thenAnswer((_) async {}); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container + .read(unBlockProvider.notifier) + .unBlockUser(participantId: 'user-42'); + + verify(() => mockRepo.unBlockUser('user-42')).called(1); + }); + }); + + // ========================================================================= + // isBlockedProvider & isBlockedByProvider wiring + // ========================================================================= + + group('isBlockedProvider wiring', () { + test('calls repo.isBlocked for given participantId', () async { + when(() => mockRepo.isBlocked('user-2')).thenAnswer((_) async => true); + + final container = buildContainer(); + addTearDown(container.dispose); + + final result = await container.read( + // ignore: invalid_use_of_internal_member + Provider>((ref) async { + final usecase = ref.read(repositoryprovider); + return usecase.isBlocked('user-2'); + }), + ); + + expect(await result, true); + }); + }); +} diff --git a/test/features/messaging/presentation/providers/mark_as_read_notifier_test.dart b/test/features/messaging/presentation/providers/mark_as_read_notifier_test.dart new file mode 100644 index 00000000..36e83bcc --- /dev/null +++ b/test/features/messaging/presentation/providers/mark_as_read_notifier_test.dart @@ -0,0 +1,146 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/messaging/domain/entities/conversation.dart'; +import 'package:rythmify/features/messaging/domain/entities/message.dart'; +import 'package:rythmify/features/messaging/domain/repositories/messaging_repository.dart'; +import 'package:rythmify/features/messaging/presentation/providers/mark_as_read_notifier.dart'; +import 'package:rythmify/features/messaging/presentation/providers/mark_as_read_provider.dart'; +import 'package:rythmify/features/messaging/presentation/providers/repository_provider.dart'; + +class MockMessagingRepository extends Mock implements MessagingRepository {} + +void main() { + late MockMessagingRepository mockRepo; + + final tDate = DateTime(2024, 6, 1); + + final tConversation = Conversation( + conversationId: 'conv-1', + participantId: 'user-2', + participantName: 'Alice', + lastMessagePreview: 'Hey', + lastMessageDate: tDate, + unReadCount: 0, + ); + + final tMessage = Message( + messageId: 'msg-1', + senderId: 'user-1', + conversationId: 'conv-1', + body: 'Hello', + isRead: false, + createdAt: tDate, + ); + + setUp(() { + mockRepo = MockMessagingRepository(); + + when(() => mockRepo.getConversations()) + .thenAnswer((_) async => [tConversation]); + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async => ([tMessage], 1)); + }); + + ProviderContainer buildContainer() { + return ProviderContainer( + overrides: [repositoryprovider.overrideWithValue(mockRepo)], + ); + } + + group('MarkAsReadNotifier', () { + test('initial state is false', () { + final container = buildContainer(); + addTearDown(container.dispose); + + expect(container.read(markAsRead), false); + }); + + test('markRead calls repo.markMessageAsRead with correct args', () async { + when(() => mockRepo.markMessageAsRead(any(), any())) + .thenAnswer((_) async {}); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container + .read(markAsRead.notifier) + .markRead(msgId: 'msg-1', convId: 'conv-1'); + + verify(() => mockRepo.markMessageAsRead('msg-1', 'conv-1')).called(1); + }); + + test('state returns to false after markRead completes', () async { + when(() => mockRepo.markMessageAsRead(any(), any())) + .thenAnswer((_) async {}); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container + .read(markAsRead.notifier) + .markRead(msgId: 'msg-1', convId: 'conv-1'); + + expect(container.read(markAsRead), false); + }); + + test('state returns to false even when exception is thrown (409 case)', + () async { + when(() => mockRepo.markMessageAsRead(any(), any())) + .thenThrow(Exception('409: Already read')); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container + .read(markAsRead.notifier) + .markRead(msgId: 'msg-1', convId: 'conv-1'); + + expect(container.read(markAsRead), false); + }); + + test('markRead calls updateMessageRead on the messages notifier', () async { + when(() => mockRepo.markMessageAsRead(any(), any())) + .thenAnswer((_) async {}); + + final container = buildContainer(); + addTearDown(container.dispose); + + // Prime the messages notifier so it has tMessage loaded + await Future.delayed(Duration.zero); + + await container + .read(markAsRead.notifier) + .markRead(msgId: 'msg-1', convId: 'conv-1'); + + // Verify repo was called (indirect proof updateMessageRead ran) + verify(() => mockRepo.markMessageAsRead('msg-1', 'conv-1')).called(1); + }); + + test('is a MarkAsReadNotifier', () { + final container = buildContainer(); + addTearDown(container.dispose); + + expect( + container.read(markAsRead.notifier), + isA(), + ); + }); + + test('markRead with different conversationId delegates correctly', () async { + when(() => mockRepo.markMessageAsRead(any(), any())) + .thenAnswer((_) async {}); + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async => ([], 0)); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container + .read(markAsRead.notifier) + .markRead(msgId: 'msg-99', convId: 'conv-99'); + + verify(() => mockRepo.markMessageAsRead('msg-99', 'conv-99')).called(1); + }); + }); +} diff --git a/test/features/messaging/presentation/providers/messages_notifier_test.dart b/test/features/messaging/presentation/providers/messages_notifier_test.dart new file mode 100644 index 00000000..6a666e26 --- /dev/null +++ b/test/features/messaging/presentation/providers/messages_notifier_test.dart @@ -0,0 +1,365 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/messaging/domain/entities/message.dart'; +import 'package:rythmify/features/messaging/domain/repositories/messaging_repository.dart'; +import 'package:rythmify/features/messaging/presentation/providers/messages_notifier.dart'; + +class MockMessagingRepository extends Mock implements MessagingRepository {} + +void main() { + late MockMessagingRepository mockRepo; + + final tDate = DateTime(2024, 6, 1); + + Message makeMessage(String id, {bool isRead = false}) => Message( + messageId: id, + senderId: 'user-1', + conversationId: 'conv-1', + body: 'Message $id', + isRead: isRead, + createdAt: tDate, + ); + + setUp(() { + mockRepo = MockMessagingRepository(); + }); + + MessagesNotifier buildNotifier({String convId = 'conv-1'}) { + return MessagesNotifier(repo: mockRepo, conversationId: convId); + } + + // ========================================================================= + // MessagesState + // ========================================================================= + + group('MessagesState', () { + test('has correct default values', () { + const state = MessagesState(); + expect(state.messages, isEmpty); + expect(state.isLoading, true); + expect(state.isLoadingMore, false); + expect(state.hasMore, false); + expect(state.error, isNull); + }); + + group('copyWith', () { + test('updates messages', () { + const state = MessagesState(); + final m = Message( + messageId: 'm1', + senderId: 'u1', + conversationId: 'c1', + isRead: false, + createdAt: DateTime(2024), + ); + final updated = state.copyWith(messages: [m]); + expect(updated.messages, [m]); + expect(updated.isLoading, true); + }); + + test('updates isLoading to false', () { + const state = MessagesState(); + final updated = state.copyWith(isLoading: false); + expect(updated.isLoading, false); + }); + + test('updates isLoadingMore to true', () { + const state = MessagesState(); + final updated = state.copyWith(isLoadingMore: true); + expect(updated.isLoadingMore, true); + }); + + test('updates hasMore to true', () { + const state = MessagesState(); + final updated = state.copyWith(hasMore: true); + expect(updated.hasMore, true); + }); + + test('sets error when provided', () { + const state = MessagesState(); + final updated = state.copyWith(error: 'something failed'); + expect(updated.error, 'something failed'); + }); + + test('clears error when error param is null', () { + const state = MessagesState(error: 'old error'); + final updated = state.copyWith(isLoading: false); + expect(updated.error, isNull); + }); + + test('preserves unchanged fields', () { + const state = MessagesState( + isLoading: false, + isLoadingMore: false, + hasMore: true, + ); + final updated = state.copyWith(isLoadingMore: true); + expect(updated.isLoading, false); + expect(updated.hasMore, true); + }); + }); + }); + + // ========================================================================= + // MessagesNotifier + // ========================================================================= + + group('MessagesNotifier', () { + group('loadInitial — total <= 100', () { + test('sets messages, isLoading=false, hasMore=false', () async { + final messages = [makeMessage('m1'), makeMessage('m2')]; + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async => (messages, 2)); + + final notifier = buildNotifier(); + await Future.delayed(Duration.zero); + + expect(notifier.state.isLoading, false); + expect(notifier.state.messages, messages); + expect(notifier.state.hasMore, false); + expect(notifier.state.error, isNull); + }); + + test('works correctly with exactly 100 messages', () async { + final messages = List.generate(100, (i) => makeMessage('m$i')); + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async => (messages, 100)); + + final notifier = buildNotifier(); + await Future.delayed(Duration.zero); + + expect(notifier.state.messages.length, 100); + expect(notifier.state.hasMore, false); + }); + }); + + group('loadInitial — total > 100', () { + test('fetches latest page and sets hasMore=true when latestOffset > 0', + () async { + final firstBatch = List.generate(100, (i) => makeMessage('first$i')); + final latestBatch = List.generate(50, (i) => makeMessage('latest$i')); + + when(() => mockRepo.getMessages(any(), offset: 0)) + .thenAnswer((_) async => (firstBatch, 150)); + when(() => mockRepo.getMessages(any(), offset: 50)) + .thenAnswer((_) async => (latestBatch, 150)); + + final notifier = buildNotifier(); + await Future.delayed(Duration.zero); + + expect(notifier.state.messages, latestBatch); + expect(notifier.state.isLoading, false); + expect(notifier.state.hasMore, true); + }); + + test('sets hasMore=false when total is exactly 200 after one loadMore', + () async { + final firstBatch = List.generate(100, (i) => makeMessage('b1_$i')); + final latestBatch = List.generate(100, (i) => makeMessage('b2_$i')); + + when(() => mockRepo.getMessages(any(), offset: 0)) + .thenAnswer((_) async => (firstBatch, 200)); + when(() => mockRepo.getMessages(any(), offset: 100)) + .thenAnswer((_) async => (latestBatch, 200)); + + final notifier = buildNotifier(); + await Future.delayed(Duration.zero); + + // latestOffset = 200 - 100 = 100, hasMore = (100 > 0) = true + expect(notifier.state.hasMore, true); + + // After loadMore, olderOffset = max(0, 100-100) = 0, hasMore = false + when(() => mockRepo.getMessages(any(), offset: 0)) + .thenAnswer((_) async => (firstBatch, 200)); + + await notifier.loadMore(); + + expect(notifier.state.hasMore, false); + expect(notifier.state.messages.first.messageId, 'b1_0'); + expect(notifier.state.messages.last.messageId, 'b2_99'); + }); + }); + + group('loadInitial — error handling', () { + test('sets error state and isLoading=false on exception', () async { + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenThrow(Exception('Network error')); + + final notifier = buildNotifier(); + await Future.delayed(Duration.zero); + + expect(notifier.state.isLoading, false); + expect(notifier.state.error, contains('Network error')); + expect(notifier.state.hasMore, false); + }); + + test('starts with isLoading=true before async resolves', () { + // Stub getMessages to never complete + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async { + await Future.delayed(const Duration(seconds: 10)); + return ([], 0); + }); + + final notifier = buildNotifier(); + expect(notifier.state.isLoading, true); + }); + }); + + group('loadMore', () { + test('prepends older messages and updates hasMore', () async { + final latestBatch = [makeMessage('latest')]; + final olderBatch = [makeMessage('older')]; + + when(() => mockRepo.getMessages(any(), offset: 0)) + .thenAnswer((_) async => (latestBatch, 150)); + when(() => mockRepo.getMessages(any(), offset: 50)) + .thenAnswer((_) async => (latestBatch, 150)); + + final notifier = buildNotifier(); + await Future.delayed(Duration.zero); + expect(notifier.state.hasMore, true); + + when(() => mockRepo.getMessages(any(), offset: 0)) + .thenAnswer((_) async => (olderBatch, 150)); + + await notifier.loadMore(); + + expect(notifier.state.messages.first.messageId, 'older'); + expect(notifier.state.messages.last.messageId, 'latest'); + expect(notifier.state.isLoadingMore, false); + expect(notifier.state.hasMore, false); + }); + + test('does nothing when hasMore is false', () async { + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async => ([makeMessage('m1')], 1)); + + final notifier = buildNotifier(); + await Future.delayed(Duration.zero); + expect(notifier.state.hasMore, false); + + clearInteractions(mockRepo); + await notifier.loadMore(); + + verifyNever(() => mockRepo.getMessages( + any(), + offset: any(named: 'offset'), + )); + }); + + test('sets isLoadingMore to false on exception during load', () async { + when(() => mockRepo.getMessages(any(), offset: 0)) + .thenAnswer((_) async => ([makeMessage('m1')], 150)); + when(() => mockRepo.getMessages(any(), offset: 50)) + .thenAnswer((_) async => ([makeMessage('m2')], 150)); + + final notifier = buildNotifier(); + await Future.delayed(Duration.zero); + + when(() => mockRepo.getMessages(any(), offset: 0)) + .thenThrow(Exception('Load failed')); + + await notifier.loadMore(); + + expect(notifier.state.isLoadingMore, false); + }); + }); + + group('refresh', () { + test('re-runs loadInitial and resets to latest page', () async { + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async => ([makeMessage('m1')], 1)); + + final notifier = buildNotifier(); + await Future.delayed(Duration.zero); + expect(notifier.state.messages.first.messageId, 'm1'); + + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async => ([makeMessage('m2')], 1)); + + await notifier.refresh(); + + expect(notifier.state.messages.first.messageId, 'm2'); + }); + }); + + group('appendMessage', () { + test('appends message to end of current list', () async { + final m1 = makeMessage('m1'); + final m2 = makeMessage('m2'); + + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async => ([m1], 1)); + + final notifier = buildNotifier(); + await Future.delayed(Duration.zero); + + notifier.appendMessage(m2); + + expect(notifier.state.messages, [m1, m2]); + }); + + test('appends to empty list', () async { + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async => ([], 0)); + + final notifier = buildNotifier(); + await Future.delayed(Duration.zero); + + notifier.appendMessage(makeMessage('new')); + + expect(notifier.state.messages.length, 1); + expect(notifier.state.messages.first.messageId, 'new'); + }); + }); + + group('updateMessageRead', () { + test('marks only the specified message as read', () async { + final m1 = makeMessage('m1', isRead: false); + final m2 = makeMessage('m2', isRead: false); + final m3 = makeMessage('m3', isRead: false); + + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async => ([m1, m2, m3], 3)); + + final notifier = buildNotifier(); + await Future.delayed(Duration.zero); + + notifier.updateMessageRead('m2'); + + expect(notifier.state.messages[0].isRead, false); + expect(notifier.state.messages[1].isRead, true); + expect(notifier.state.messages[2].isRead, false); + }); + + test('does not change anything for non-existing messageId', () async { + final m1 = makeMessage('m1', isRead: false); + + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async => ([m1], 1)); + + final notifier = buildNotifier(); + await Future.delayed(Duration.zero); + + notifier.updateMessageRead('non-existent'); + + expect(notifier.state.messages.first.isRead, false); + }); + + test('can mark an already-read message (no-op on isRead)', () async { + final m1 = makeMessage('m1', isRead: true); + + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async => ([m1], 1)); + + final notifier = buildNotifier(); + await Future.delayed(Duration.zero); + + notifier.updateMessageRead('m1'); + + expect(notifier.state.messages.first.isRead, true); + }); + }); + }); +} diff --git a/test/features/messaging/presentation/providers/messaging_providers_wiring_test.dart b/test/features/messaging/presentation/providers/messaging_providers_wiring_test.dart new file mode 100644 index 00000000..80cb83a1 --- /dev/null +++ b/test/features/messaging/presentation/providers/messaging_providers_wiring_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/messaging/domain/entities/conversation.dart'; +import 'package:rythmify/features/messaging/domain/repositories/messaging_repository.dart'; +import 'package:rythmify/features/messaging/presentation/providers/conversations_provider.dart'; +import 'package:rythmify/features/messaging/presentation/providers/is_blocked_by_provider.dart'; +import 'package:rythmify/features/messaging/presentation/providers/is_blocked_provider.dart'; +import 'package:rythmify/features/messaging/presentation/providers/repository_provider.dart'; + +class MockMessagingRepository extends Mock implements MessagingRepository {} + +void main() { + late MockMessagingRepository mockRepo; + + final tDate = DateTime(2024, 6, 1); + + final tConversation = Conversation( + conversationId: 'conv-1', + participantId: 'user-2', + participantName: 'Alice', + lastMessagePreview: 'Hey', + lastMessageDate: tDate, + unReadCount: 0, + ); + + setUp(() { + mockRepo = MockMessagingRepository(); + }); + + ProviderContainer buildContainer() { + return ProviderContainer( + overrides: [repositoryprovider.overrideWithValue(mockRepo)], + ); + } + + group('conversationProvider wiring', () { + test('executes GetConversationsUsecase and returns conversations', () async { + when(() => mockRepo.getConversations()) + .thenAnswer((_) async => [tConversation]); + + final container = buildContainer(); + addTearDown(container.dispose); + + final result = await container.read(conversationProvider.future); + + expect(result, [tConversation]); + verify(() => mockRepo.getConversations()).called(1); + }); + + test('returns empty list when repo has no conversations', () async { + when(() => mockRepo.getConversations()).thenAnswer((_) async => []); + + final container = buildContainer(); + addTearDown(container.dispose); + + final result = await container.read(conversationProvider.future); + + expect(result, isEmpty); + }); + }); + + group('isBlockedProvider wiring', () { + test('executes IsBlockedUsecase and returns true when blocked', () async { + when(() => mockRepo.isBlocked(any())).thenAnswer((_) async => true); + + final container = buildContainer(); + addTearDown(container.dispose); + + final result = + await container.read(isBlockedProvider('user-2').future); + + expect(result, true); + verify(() => mockRepo.isBlocked('user-2')).called(1); + }); + + test('returns false when participant is not blocked', () async { + when(() => mockRepo.isBlocked(any())).thenAnswer((_) async => false); + + final container = buildContainer(); + addTearDown(container.dispose); + + final result = + await container.read(isBlockedProvider('user-3').future); + + expect(result, false); + }); + }); + + group('isBlockedByProvider wiring', () { + test('executes IsBlockedByUsecase and returns true when blocked by', () async { + when(() => mockRepo.isBlockedBy(any())).thenAnswer((_) async => true); + + final container = buildContainer(); + addTearDown(container.dispose); + + final result = + await container.read(isBlockedByProvider('user-2').future); + + expect(result, true); + verify(() => mockRepo.isBlockedBy('user-2')).called(1); + }); + + test('returns false when not blocked by participant', () async { + when(() => mockRepo.isBlockedBy(any())).thenAnswer((_) async => false); + + final container = buildContainer(); + addTearDown(container.dispose); + + final result = + await container.read(isBlockedByProvider('user-3').future); + + expect(result, false); + }); + }); +} diff --git a/test/features/messaging/presentation/providers/send_message_notifier_test.dart b/test/features/messaging/presentation/providers/send_message_notifier_test.dart new file mode 100644 index 00000000..dd06dae4 --- /dev/null +++ b/test/features/messaging/presentation/providers/send_message_notifier_test.dart @@ -0,0 +1,264 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/messaging/data/datasources/data_sources_sockets.dart'; +import 'package:rythmify/features/messaging/domain/entities/conversation.dart'; +import 'package:rythmify/features/messaging/domain/entities/message.dart'; +import 'package:rythmify/features/messaging/domain/repositories/messaging_repository.dart'; +import 'package:rythmify/features/messaging/presentation/providers/repository_provider.dart'; +import 'package:rythmify/features/messaging/presentation/providers/send_message_notifier.dart'; +import 'package:rythmify/features/messaging/presentation/providers/send_message_provider.dart'; +import 'package:rythmify/features/messaging/presentation/providers/socket_provider.dart'; + +class MockMessagingRepository extends Mock implements MessagingRepository {} + +class MockDataSourcesSockets extends Mock implements DataSourcesSockets {} + +void main() { + late MockMessagingRepository mockRepo; + late MockDataSourcesSockets mockSocket; + + final tDate = DateTime(2024, 6, 1); + + final tConversation = Conversation( + conversationId: 'conv-1', + participantId: 'user-2', + participantName: 'Alice', + lastMessagePreview: 'Hey', + lastMessageDate: tDate, + unReadCount: 0, + ); + + final tMessage = Message( + messageId: 'msg-1', + senderId: 'user-1', + conversationId: 'conv-1', + body: 'Hello', + isRead: false, + createdAt: tDate, + ); + + setUp(() { + mockRepo = MockMessagingRepository(); + mockSocket = MockDataSourcesSockets(); + + when(() => mockSocket.sendMessage(any(), any())).thenReturn(null); + + when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) + .thenAnswer((_) async => ([tMessage], 1)); + + when(() => mockRepo.getConversations()) + .thenAnswer((_) async => [tConversation]); + }); + + ProviderContainer buildContainer() { + return ProviderContainer( + overrides: [ + repositoryprovider.overrideWithValue(mockRepo), + socketProvider.overrideWithValue(mockSocket), + ], + ); + } + + group('SendMessageNotifier', () { + test('initial state is false (not loading)', () { + final container = buildContainer(); + addTearDown(container.dispose); + + expect(container.read(sendMessageProvider), false); + }); + + group('sendMessage — with existing conversationId', () { + test('calls repo.sendMessage and returns null', () async { + when(() => mockRepo.sendMessage(any(), any(), any(), any())) + .thenAnswer((_) async => tMessage); + + final container = buildContainer(); + addTearDown(container.dispose); + + final result = await container + .read(sendMessageProvider.notifier) + .sendMessage(conversationId: 'conv-1', body: 'Hello'); + + expect(result, isNull); + verify(() => mockRepo.sendMessage('conv-1', 'Hello', null, null)) + .called(1); + }); + + test('state returns to false after completion', () async { + when(() => mockRepo.sendMessage(any(), any(), any(), any())) + .thenAnswer((_) async => tMessage); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container.read(sendMessageProvider.notifier).sendMessage( + conversationId: 'conv-1', + body: 'Hello', + ); + + expect(container.read(sendMessageProvider), false); + }); + + test('calls socket.sendMessage with conversation data', () async { + when(() => mockRepo.sendMessage(any(), any(), any(), any())) + .thenAnswer((_) async => tMessage); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container.read(sendMessageProvider.notifier).sendMessage( + conversationId: 'conv-1', + body: 'Hello', + ); + + verify(() => mockSocket.sendMessage('conv-1', any())).called(1); + }); + + test('passes embedId and embedType to repo.sendMessage', () async { + final embedMessage = Message( + messageId: 'msg-2', + senderId: 'user-1', + conversationId: 'conv-1', + embedId: 'track-1', + embedType: 'track', + isRead: false, + createdAt: tDate, + ); + when(() => mockRepo.sendMessage(any(), any(), any(), any())) + .thenAnswer((_) async => embedMessage); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container.read(sendMessageProvider.notifier).sendMessage( + conversationId: 'conv-1', + embedId: 'track-1', + embedType: 'track', + ); + + verify(() => mockRepo.sendMessage('conv-1', null, 'track-1', 'track')) + .called(1); + }); + }); + + group('sendMessage — without conversationId (new conversation)', () { + test('calls repo.startConversation and returns new conversation', () async { + when(() => mockRepo.startConversation(any(), any(), any(), any())) + .thenAnswer((_) async => tConversation); + + final container = buildContainer(); + addTearDown(container.dispose); + + final result = await container + .read(sendMessageProvider.notifier) + .sendMessage( + newParticipantId: 'user-2', + body: 'Hello', + ); + + expect(result, tConversation); + verify(() => mockRepo.startConversation('user-2', 'Hello', null, null)) + .called(1); + }); + + test('routes track embedId to trackId', () async { + when(() => mockRepo.startConversation(any(), any(), any(), any())) + .thenAnswer((_) async => tConversation); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container.read(sendMessageProvider.notifier).sendMessage( + newParticipantId: 'user-2', + embedId: 'track-123', + embedType: 'track', + ); + + verify(() => mockRepo.startConversation( + 'user-2', + null, + 'track-123', + null, + )).called(1); + }); + + test('routes playlist embedId to playlistId', () async { + when(() => mockRepo.startConversation(any(), any(), any(), any())) + .thenAnswer((_) async => tConversation); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container.read(sendMessageProvider.notifier).sendMessage( + newParticipantId: 'user-2', + embedId: 'pl-456', + embedType: 'playlist', + ); + + verify(() => mockRepo.startConversation( + 'user-2', + null, + null, + 'pl-456', + )).called(1); + }); + + test('state returns to false after new conversation is created', () async { + when(() => mockRepo.startConversation(any(), any(), any(), any())) + .thenAnswer((_) async => tConversation); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container.read(sendMessageProvider.notifier).sendMessage( + newParticipantId: 'user-2', + ); + + expect(container.read(sendMessageProvider), false); + }); + }); + + group('ensureConversation', () { + test('calls repo.ensureConversation and returns conversation', () async { + when(() => mockRepo.ensureConversation(any())) + .thenAnswer((_) async => tConversation); + + final container = buildContainer(); + addTearDown(container.dispose); + + final result = await container + .read(sendMessageProvider.notifier) + .ensureConversation('user-2'); + + expect(result, tConversation); + verify(() => mockRepo.ensureConversation('user-2')).called(1); + }); + + test('state returns to false after completion', () async { + when(() => mockRepo.ensureConversation(any())) + .thenAnswer((_) async => tConversation); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container + .read(sendMessageProvider.notifier) + .ensureConversation('user-2'); + + expect(container.read(sendMessageProvider), false); + }); + }); + + test('is a StateNotifier', () { + final container = buildContainer(); + addTearDown(container.dispose); + + expect( + container.read(sendMessageProvider.notifier), + isA(), + ); + }); + }); +} diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 9a52c92b..1e414dac 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -18,7 +18,6 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flutter_local_notifications_windows - jni ) set(PLUGIN_BUNDLED_LIBRARIES) From f519968d7872e31c3df22b4dad88120091ea01a4 Mon Sep 17 00:00:00 2001 From: RanaElgharabawy Date: Fri, 1 May 2026 23:20:35 +0300 Subject: [PATCH 4/7] settings fixes --- .../presentation/pages/social_settings_screen.dart | 13 ------------- .../providers/privacy_settings_notifier.dart | 8 +++++--- .../providers/privacy_settings_notifier_test.dart | 7 +++++-- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/lib/features/settings/presentation/pages/social_settings_screen.dart b/lib/features/settings/presentation/pages/social_settings_screen.dart index 8e11289e..862f8ab6 100644 --- a/lib/features/settings/presentation/pages/social_settings_screen.dart +++ b/lib/features/settings/presentation/pages/social_settings_screen.dart @@ -62,19 +62,6 @@ class SocialSettingsScreen extends ConsumerWidget { .set(value); }, ), - SwitchTileWidget( - key: const Key('show_activities_tile'), - title: - 'Show my activities in social discovery playlists and modules', - subtitle: - "Your Likes, Reactions and other engagement may be shown to other users in discovery features such as 'Liked By' playlists or update feeds. Turning this off won't hide your Likes on your profile or tracks.", - initSwitchValue: data.showActivitiesInDiscovery, - onSwitchChanged: (value) { - ref - .read(privacySettingsProvider.notifier) - .save(data.copyWith(showActivitiesInDiscovery: value)); - }, - ), const SizedBox(height: 2), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/lib/features/settings/presentation/providers/privacy_settings_notifier.dart b/lib/features/settings/presentation/providers/privacy_settings_notifier.dart index 2a6e17b4..27ae8b0e 100644 --- a/lib/features/settings/presentation/providers/privacy_settings_notifier.dart +++ b/lib/features/settings/presentation/providers/privacy_settings_notifier.dart @@ -18,13 +18,15 @@ class PrivacySettingsNotifier extends AsyncNotifier { state = AsyncData(updated); try { final old = previous.asData?.value ?? updated; - final saved = await ref + await ref .read(settingsRepositoryProvider) .updatePrivacySettings(old, updated); - state = AsyncData(saved); + // Keep optimistic state — server confirmed the patch. + // Do NOT overwrite with the server response: fields absent from the + // PATCH response are defaulted by fromJson and would silently revert + // toggles the user just set (e.g. show_as_top_fan flipping back to true). } catch (_) { // Keep optimistic state — backend row may not exist yet for this account. - // The toggle remains visually applied for the session. } } } diff --git a/test/features/settings/presentation/providers/privacy_settings_notifier_test.dart b/test/features/settings/presentation/providers/privacy_settings_notifier_test.dart index c17255ff..49dd531b 100644 --- a/test/features/settings/presentation/providers/privacy_settings_notifier_test.dart +++ b/test/features/settings/presentation/providers/privacy_settings_notifier_test.dart @@ -55,7 +55,7 @@ void main() { }); group('save', () { - test('optimistically applies updated entity then commits server response', () async { + test('optimistically applies updated entity and keeps it after confirmed patch', () async { when(() => mockRepo.getPrivacySettings()) .thenAnswer((_) async => tPrivacyEntity); @@ -74,8 +74,11 @@ void main() { await container.read(privacySettingsProvider.future); await container.read(privacySettingsProvider.notifier).save(updated); + // Optimistic state is kept — server response is NOT used to overwrite state. + // This prevents fields absent from the PATCH response (defaulted by fromJson) + // from reverting toggles the user just set. final state = container.read(privacySettingsProvider); - expect(state.value, serverResponse); + expect(state.value, updated); verify(() => mockRepo.updatePrivacySettings(any(), any())).called(1); }); From d076e94c8a3a6ec9c82634f5ae240d51fa4152a2 Mon Sep 17 00:00:00 2001 From: RanaElgharabawy Date: Sat, 2 May 2026 00:37:54 +0300 Subject: [PATCH 5/7] docs --- android/app/src/main/res/values/colors.xml | 12 +- .../datasources/datasource_implement.dart | 51 ++ .../data/models/shared_embed_model.dart | 5 + .../repositories/repository_implement.dart | 27 + .../usecases/ensure_conversation_usecase.dart | 6 + .../notification_datasources_impl.dart | 20 +- .../data/models/notification_model.dart | 7 + .../repositories/notifications_repo_impl.dart | 9 + .../domain/entities/notification_entity.dart | 13 + .../settings_datasources_impl.dart | 16 + .../notification_preferences_model.dart | 13 + .../data/models/privacy_settings_model.dart | 17 + .../data/repositories/settings_repo_impl.dart | 12 + .../notification_preferences_entity.dart | 11 + .../entities/privacy_settings_entity.dart | 15 + .../repositories/settings_repo_interface.dart | 20 + .../presentation/pages/app_icon_screen.dart | 17 +- .../pages/social_settings_screen.dart | 12 + .../notification_prefs_notifier.dart | 12 + .../providers/privacy_settings_notifier.dart | 13 + .../datasource_implement_test.dart | 747 ++++++++++++++++++ 21 files changed, 1033 insertions(+), 22 deletions(-) create mode 100644 test/features/messaging/data/datasources/datasource_implement_test.dart diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index ff07431e..ca9cb769 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -2,11 +2,11 @@ #121212 #FF5500 - #C0C0C0 - #B76E79 + #78909C + #C5848F #A8A9AD - #121212 - #121212 - #121212 - #121212 + #A78BFA + #FF3EA5 + #4ECDC4 + #8B5E3C \ No newline at end of file diff --git a/lib/features/messaging/data/datasources/datasource_implement.dart b/lib/features/messaging/data/datasources/datasource_implement.dart index b1df3dd8..e9aeddcb 100644 --- a/lib/features/messaging/data/datasources/datasource_implement.dart +++ b/lib/features/messaging/data/datasources/datasource_implement.dart @@ -17,6 +17,9 @@ class DatasourceImplement implements DatasourceInterface { DatasourceImplement({required this.dio}); @override + /// Fetches all conversations for the current user from `GET /messages/conversations`. + /// + /// Expects a JSON envelope `{ data: { items: [...] } }`. Future> getConversations() async { final response = await dio.get(ApiEndPoints.getConversations); if (response.data is! Map) { @@ -34,6 +37,11 @@ class DatasourceImplement implements DatasourceInterface { } @override + /// Fetches a page of messages for [conversationId] at [offset]. + /// + /// Handles two response shapes: `data` as a Map containing `messages` + `pagination`, + /// or `data` as a raw List. Falls back to `rawMessages.length` when `total` is absent. + /// Returns a tuple of the parsed [MessageModel] list and the total message count. Future<(List, int)> getMessages({ required String conversationId, int offset = 0, @@ -74,6 +82,9 @@ class DatasourceImplement implements DatasourceInterface { } @override + /// Sends a message to [conversationId] via `POST /messages/conversations/{id}/messages`. + /// + /// Serialises [requestContent] to JSON and returns the created [MessageModel]. Future sendMessage({ required String conversationId, required SentMessageRequestModel requestContent, @@ -86,6 +97,11 @@ class DatasourceImplement implements DatasourceInterface { } @override + /// Starts a new conversation with [participantId] via `POST /messages/new`. + /// + /// Optionally attaches a text [body] or a music resource ([trackId] or [playlistId]). + /// If the response does not contain a `conversation` key, falls back to fetching + /// the conversation list and finding the one matching [participantId]. Future newConversation({ required String participantId, String? body, @@ -114,6 +130,10 @@ class DatasourceImplement implements DatasourceInterface { } @override + /// Ensures a conversation with [participantId] exists via `POST /messages/conversations/ensure`. + /// + /// Creates the conversation if it doesn't exist; returns the existing one otherwise. + /// Used when navigating to a chat screen without sending a message. Future ensureConversation({ required String participantId, }) async { @@ -127,22 +147,29 @@ class DatasourceImplement implements DatasourceInterface { } @override + /// Returns the total number of unread messages via `GET /messages/unread-count`. Future getUnreadCount() async { final response = await dio.get(ApiEndPoints.getUnreadCount); return response.data['data']['unread_count'] as int; } @override + /// Blocks [userId] via `POST /users/{userId}/block`. Future blockUser({required String userId}) async { await dio.post(ApiEndPoints.blockUser(userId)); } @override + /// Unblocks [userId] via `DELETE /users/{userId}/block`. Future unBlockUser({required String userId}) async { await dio.delete(ApiEndPoints.unBlockUser(userId)); } @override + /// Marks [messageId] as read via `PATCH /messages/conversations/{convId}/messages/{msgId}/read`. + /// + /// A 409 response means the message is already read and is silently swallowed. + /// All other [DioException]s are rethrown. Future markMessagesAsRead({ required String conversationId, required String messageId, @@ -159,6 +186,7 @@ class DatasourceImplement implements DatasourceInterface { } @override + /// Returns the list of users followed by the current user via `GET /users/me/following`. Future> getFollowings(String myId) async { try { final url = ApiEndPoints.getFollowings(); @@ -180,6 +208,7 @@ class DatasourceImplement implements DatasourceInterface { } @override + /// Searches for users matching [query] via `GET /search?q={query}&type=users`. Future> getSearchedUsers( String query, ) async { @@ -202,6 +231,9 @@ class DatasourceImplement implements DatasourceInterface { } @override + /// Returns `true` if the current user has blocked [participantId]. + /// + /// Reads the `is_blocking` field from `GET /users/{id}/follow-status`. Future isBlocked(String participantId) async { final response = await dio.get(ApiEndPoints.isBlocked(participantId)); @@ -217,6 +249,10 @@ class DatasourceImplement implements DatasourceInterface { } @override + /// Returns `true` if [participantId] has blocked the current user. + /// + /// Reads the `is_blocked_by` field from the same `GET /users/{id}/follow-status` + /// endpoint used by [isBlocked]. Future isBlockedBy(String participantId) async { final response = await dio.get( ApiEndPoints.isBlocked(participantId), //same endpoint as isBlocked @@ -234,6 +270,12 @@ class DatasourceImplement implements DatasourceInterface { } @override + /// Returns the current user's liked embeds filtered by [embedType]. + /// + /// - `track`: fetches liked tracks from `GET /me/liked-tracks`. + /// - `playlist`: merges liked playlists and user-created playlists, + /// deduplicating by ID so the same playlist never appears twice. + /// - `album`: fetches liked albums from `GET /me/liked-albums`. Future> getEmbeds( String userId, String embedType, @@ -303,6 +345,10 @@ class DatasourceImplement implements DatasourceInterface { } @override + /// Fetches track metadata for [trackId] from `GET /tracks/{trackId}`. + /// + /// Tries `artist_name`, then `artist`, then `artists` for the artist field + /// to handle inconsistencies across different track response shapes. Future getTrackDetails(String trackId) async { final response = await dio.get(ApiEndPoints.getTrackDetails(trackId)); final data = response.data['data']; @@ -316,6 +362,11 @@ class DatasourceImplement implements DatasourceInterface { } @override + /// Fetches playlist or album metadata from `GET /playlists/{playlistId}`. + /// + /// [embedType] is passed through to [SharedEmbedModel] to distinguish + /// between `playlist` and `album` at the domain level. + /// Falls back to [playlistId] when `playlist_id` is absent from the response. Future getPlaylistDetails( String playlistId, String embedType, diff --git a/lib/features/messaging/data/models/shared_embed_model.dart b/lib/features/messaging/data/models/shared_embed_model.dart index 5ed1407d..02fd30df 100644 --- a/lib/features/messaging/data/models/shared_embed_model.dart +++ b/lib/features/messaging/data/models/shared_embed_model.dart @@ -15,6 +15,11 @@ class SharedEmbedModel extends SharedEmbed { super.thumbnailUrl, }); + /// Deserialises a [SharedEmbedModel] from an already-normalised [json] map. + /// + /// This factory is used internally after the datasource has normalised + /// field names (e.g. mapping `track_id` → `embedId`). It is NOT called + /// directly on raw API responses. factory SharedEmbedModel.fromJson(Map json) { return SharedEmbedModel( embedId: json['embedId'] as String, diff --git a/lib/features/messaging/data/repositories/repository_implement.dart b/lib/features/messaging/data/repositories/repository_implement.dart index 8cd63f60..33f98bae 100644 --- a/lib/features/messaging/data/repositories/repository_implement.dart +++ b/lib/features/messaging/data/repositories/repository_implement.dart @@ -17,11 +17,15 @@ class RepositoryImplement implements MessagingRepository { RepositoryImplement({required this.dataSource}); @override + /// Fetches all active conversations for the current user. Future> getConversations() { return dataSource.getConversations(); } @override + /// Fetches a paginated page of messages for [conversationId] starting at [offset]. + /// + /// Returns a tuple of the message list and the total message count. Future<(List, int)> getMessages( String conversationId, { int offset = 0, @@ -33,6 +37,9 @@ class RepositoryImplement implements MessagingRepository { } @override + /// Starts a new conversation with [participantId]. + /// + /// Passes an optional [body] text and/or a [trackId] or [playlistId] embed. Future startConversation( String participantId, String? body, @@ -48,6 +55,10 @@ class RepositoryImplement implements MessagingRepository { } @override + /// Sends a message within an existing [conversationId]. + /// + /// Builds a [SentMessageRequestModel] from the provided fields before + /// delegating to the datasource. Future sendMessage( String conversationId, String? body, @@ -66,16 +77,19 @@ class RepositoryImplement implements MessagingRepository { } @override + /// Blocks the user identified by [participantId]. Future blockUser(String participantId) { return dataSource.blockUser(userId: participantId); } @override + /// Unblocks the user identified by [participantId]. Future unBlockUser(String participantId) { return dataSource.unBlockUser(userId: participantId); } @override + /// Marks [messageId] as read within [conversationId]. Future markMessageAsRead(String messageId, String conversationId) { return dataSource.markMessagesAsRead( conversationId: conversationId, @@ -84,46 +98,59 @@ class RepositoryImplement implements MessagingRepository { } @override + /// Returns the total count of unread messages across all conversations. Future getUnReadCount() { return dataSource.getUnreadCount(); } @override + /// Returns the list of users followed by the current user ([myId]). Future> getFollowings(String myId) { return dataSource.getFollowings(myId); } @override + /// Searches for users matching [query]. Future> getSearchedUsers(String query) { return dataSource.getSearchedUsers(query); } @override + /// Returns `true` if the current user has blocked [participantId]. Future isBlocked(String participantId) { return dataSource.isBlocked(participantId); } @override + /// Returns `true` if [participantId] has blocked the current user. Future isBlockedBy(String participantId) { return dataSource.isBlockedBy(participantId); } @override + /// Returns the current user's liked embeds filtered by [embedType]. + /// + /// [embedType] is one of `track`, `playlist`, or `album`. Future> getLikedEmbeds(String userId, String embedType) { return dataSource.getEmbeds(userId, embedType); } @override + /// Returns track metadata for [trackId] as a [SharedEmbed]. Future getTrack(String trackId) { return dataSource.getTrackDetails(trackId); } @override + /// Returns playlist or album metadata for [playlistId] as a [SharedEmbed]. + /// + /// [embedType] distinguishes between `playlist` and `album`. Future getPlaylist(String playlistId, String embedType) { return dataSource.getPlaylistDetails(playlistId, embedType); } @override + /// Ensures a conversation with [participantId] exists, creating one if needed. Future ensureConversation(String participantId) { return dataSource.ensureConversation(participantId: participantId); } diff --git a/lib/features/messaging/domain/usecases/ensure_conversation_usecase.dart b/lib/features/messaging/domain/usecases/ensure_conversation_usecase.dart index 203f33f8..a037e7e6 100644 --- a/lib/features/messaging/domain/usecases/ensure_conversation_usecase.dart +++ b/lib/features/messaging/domain/usecases/ensure_conversation_usecase.dart @@ -1,11 +1,17 @@ import 'package:rythmify/features/messaging/domain/entities/conversation.dart'; import 'package:rythmify/features/messaging/domain/repositories/messaging_repository.dart'; +/// Ensures a conversation exists with [participantId] without sending a message. +/// +/// Used when navigating directly to a chat screen — guarantees a conversation +/// record exists on the backend before any messages are loaded or sent. +/// If the conversation already exists, the server returns the existing one. class EnsureConversationUsecase { final MessagingRepository repo; EnsureConversationUsecase({required this.repo}); + /// Returns the existing or newly created [Conversation] with [participantId]. Future call(String participantId) { return repo.ensureConversation(participantId); } diff --git a/lib/features/notifications/data/datasources/notification_datasources_impl.dart b/lib/features/notifications/data/datasources/notification_datasources_impl.dart index 5b5db254..6f52d5a3 100644 --- a/lib/features/notifications/data/datasources/notification_datasources_impl.dart +++ b/lib/features/notifications/data/datasources/notification_datasources_impl.dart @@ -3,12 +3,20 @@ import 'package:rythmify/features/notifications/data/datasources/notification_re import 'package:rythmify/features/notifications/data/models/notification_model.dart'; import 'package:rythmify/features/notifications/domain/entities/notification_entity.dart'; -/// HTTP implementation of [NotificationRemoteDatasources]. +/// HTTP implementation of [NotificationRemoteDatasources] using Dio. +/// +/// All endpoints are relative to the base URL configured in [ApiClient]. class NotificationDatasourcesImpl implements NotificationRemoteDatasources { final Dio dio; + NotificationDatasourcesImpl(this.dio); @override + /// Fetches a paginated list of notifications from `GET /notifications`. + /// + /// [newPostByFollowed] notifications are filtered out at this layer — they + /// have no dedicated UI tile. Pagination state is derived from `has_next` + /// inside the `pagination` sub-object. Future<({List items, int unreadCount, bool hasNext})> getNotifications({ int page = 1, @@ -44,23 +52,33 @@ class NotificationDatasourcesImpl implements NotificationRemoteDatasources { } @override + /// Returns the unread notification count from `GET /notifications/unread-count`. Future getUnreadCount() async { final result = await dio.get('/notifications/unread-count'); return result.data['data']['unread_count'] as int; } @override + /// Marks [notificationId] as read via `PATCH /notifications/{id}/read`. + /// + /// Called fire-and-forget from [NotificationsNotifier]; errors are not surfaced to the UI. Future markNotificationAsRead(String notificationId) async { await dio.patch('/notifications/$notificationId/read'); } @override + /// Returns whether the current user follows [userId] via `GET /users/{userId}/follow-status`. + /// + /// Defaults to `false` when the `is_following` field is absent. Future getFollowStatus(String userId) async { final result = await dio.get('/users/$userId/follow-status'); return result.data['data']['is_following'] as bool? ?? false; } @override + /// Returns the `track_id` and `is_liked_by_me` for [commentId] via `GET /comments/{commentId}`. + /// + /// Used when tapping a comment notification to resolve the parent track for navigation. Future<({String? trackId, bool isLikedByMe})> getTrackIdByCommentId( String commentId, ) async { diff --git a/lib/features/notifications/data/models/notification_model.dart b/lib/features/notifications/data/models/notification_model.dart index 297658bb..c4f8c246 100644 --- a/lib/features/notifications/data/models/notification_model.dart +++ b/lib/features/notifications/data/models/notification_model.dart @@ -54,6 +54,10 @@ class NotificationModel extends NotificationEntity { ); } + /// Maps the raw API `type` string to a [NotificationType] enum value. + /// + /// Defaults to [NotificationType.newPostByFollowed] for unrecognised values, + /// which are then filtered out at the datasource level. static NotificationType _mapType(String type) { switch (type) { case 'follow': @@ -71,6 +75,9 @@ class NotificationModel extends NotificationEntity { } } + /// Maps the raw API `resource_type` string to a [ResourceType] enum value. + /// + /// Returns `null` for follow notifications, which have no associated resource. static ResourceType? _mapResourceType(String? type) { switch (type) { case 'track': diff --git a/lib/features/notifications/data/repositories/notifications_repo_impl.dart b/lib/features/notifications/data/repositories/notifications_repo_impl.dart index fa61afa6..9a19eedf 100644 --- a/lib/features/notifications/data/repositories/notifications_repo_impl.dart +++ b/lib/features/notifications/data/repositories/notifications_repo_impl.dart @@ -10,6 +10,9 @@ class NotificationsRepoImpl implements NotificationsRepoInterface { NotificationsRepoImpl(this.datasource); @override + /// Fetches a paginated page of notifications. + /// + /// Delegates to [NotificationRemoteDatasources.getNotifications]. Future<({List items, int unreadCount, bool hasNext})> getNotifications({ int page = 1, @@ -24,17 +27,23 @@ class NotificationsRepoImpl implements NotificationsRepoInterface { ); @override + /// Returns the total count of unread notifications. Future getUnreadCount() => datasource.getUnreadCount(); @override + /// Marks the notification identified by [notificationId] as read. Future markNotificationAsRead(String notificationId) => datasource.markNotificationAsRead(notificationId); @override + /// Returns `true` if the current user follows [userId]. Future getFollowStatus(String userId) => datasource.getFollowStatus(userId); @override + /// Returns the `track_id` and `is_liked_by_me` for the given [commentId]. + /// + /// Used when tapping a comment notification to navigate to the correct track. Future<({String? trackId, bool isLikedByMe})> getTrackIdByCommentId( String commentId, ) => datasource.getTrackIdByCommentId(commentId); diff --git a/lib/features/notifications/domain/entities/notification_entity.dart b/lib/features/notifications/domain/entities/notification_entity.dart index df86197d..e4d9a15f 100644 --- a/lib/features/notifications/domain/entities/notification_entity.dart +++ b/lib/features/notifications/domain/entities/notification_entity.dart @@ -1,9 +1,18 @@ import 'package:equatable/equatable.dart'; /// The category of action that triggered a notification. +/// +/// - [follow] — another user started following the current user. +/// - [like] — a track or playlist was liked. +/// - [repost] — a track was reposted. +/// - [comment] — a comment was left on a track. +/// - [newPostByFollowed] — a followed user uploaded a new track. Filtered out +/// at the data layer and never shown in the UI. enum NotificationType { follow, like, repost, comment, newPostByFollowed } /// The kind of content the notification refers to. +/// +/// Used to determine the navigation target when a notification is tapped. enum ResourceType { track, playlist, comment } /// Core domain object representing a single in-app notification. @@ -62,6 +71,10 @@ class NotificationEntity extends Equatable { required this.createdAt, }); + /// Returns a copy of this notification with [isRead] or [isActorFollowed] replaced. + /// + /// [isActorFollowed] is not derived from the API — it is injected by + /// [FollowStateNotifier] after a separate follow-status fetch. NotificationEntity copyWith({bool? isRead, bool? isActorFollowed}) { return NotificationEntity( id: id, diff --git a/lib/features/settings/data/datasources/settings_datasources_impl.dart b/lib/features/settings/data/datasources/settings_datasources_impl.dart index a56248a8..bd38683e 100644 --- a/lib/features/settings/data/datasources/settings_datasources_impl.dart +++ b/lib/features/settings/data/datasources/settings_datasources_impl.dart @@ -3,12 +3,19 @@ import 'package:rythmify/features/settings/data/datasources/settings_remote_data import 'package:rythmify/features/settings/data/models/notification_preferences_model.dart'; import 'package:rythmify/features/settings/data/models/privacy_settings_model.dart'; +/// HTTP implementation of [SettingsRemoteDatasources] using [ApiClient]. +/// +/// All endpoints are relative to the base URL configured in [ApiClient]. class SettingsDatasourcesImpl implements SettingsRemoteDatasources { final ApiClient _apiClient; SettingsDatasourcesImpl(this._apiClient); @override + /// Fetches privacy settings from `GET /users/me/privacy-settings`. + /// + /// Falls back to an empty map when the response `data` field is absent, + /// so [PrivacySettingsModel.fromJson] can apply its own defaults. Future getPrivacySettings() async { final response = await _apiClient.dio.get('/users/me/privacy-settings'); final data = response.data; @@ -19,6 +26,10 @@ class SettingsDatasourcesImpl implements SettingsRemoteDatasources { } @override + /// Applies [fields] as a partial update via `PATCH /users/me/privacy-settings`. + /// + /// Only the keys present in [fields] are sent; the server merges them with + /// existing values. Falls back to an empty map if `data` is absent. Future patchPrivacySettings( Map fields, ) async { @@ -34,6 +45,7 @@ class SettingsDatasourcesImpl implements SettingsRemoteDatasources { } @override + /// Fetches notification preferences from `GET /notifications/preferences`. Future getNotificationPreferences() async { final response = await _apiClient.dio.get('/notifications/preferences'); return NotificationPreferencesModel.fromJson( @@ -42,6 +54,9 @@ class SettingsDatasourcesImpl implements SettingsRemoteDatasources { } @override + /// Persists [notificationPreferences] via `PATCH /notifications/preferences`. + /// + /// Serialises the full preferences object; the server replaces all preference fields. Future updateNotificationPreferences( NotificationPreferencesModel notificationPreferences, ) async { @@ -55,6 +70,7 @@ class SettingsDatasourcesImpl implements SettingsRemoteDatasources { } @override + /// Permanently deletes the authenticated user's account via `DELETE /users/me`. Future deleteMyAccount() async { await _apiClient.dio.delete('/users/me'); } diff --git a/lib/features/settings/data/models/notification_preferences_model.dart b/lib/features/settings/data/models/notification_preferences_model.dart index 9d692b1b..62e7d1ab 100644 --- a/lib/features/settings/data/models/notification_preferences_model.dart +++ b/lib/features/settings/data/models/notification_preferences_model.dart @@ -1,5 +1,8 @@ import '../../domain/entities/notification_preferences_entity.dart'; +/// Converts an API `messages_from` string value to a [MessagesFrom] enum. +/// +/// Defaults to [MessagesFrom.everyone] for null or unrecognised values. MessagesFrom _messagesFromString(String? value) { switch (value) { case 'followers_only': @@ -11,6 +14,7 @@ MessagesFrom _messagesFromString(String? value) { } } +/// Serialises a [MessagesFrom] enum to the API string representation. String _messagesFromToString(MessagesFrom value) { switch (value) { case MessagesFrom.followersOnly: @@ -22,6 +26,11 @@ String _messagesFromToString(MessagesFrom value) { } } +/// Data-layer representation of [NotificationPreferencesEntity]. +/// +/// Adds JSON serialisation ([fromJson], [toJson]) and conversion helpers +/// ([fromEntity], [toDomain]) for translating between API and domain types. +/// All boolean fields default to `true` when absent from the API response. class NotificationPreferencesModel extends NotificationPreferencesEntity { const NotificationPreferencesModel({ required super.newFollowerPush, @@ -48,6 +57,7 @@ class NotificationPreferencesModel extends NotificationPreferencesEntity { required super.newsletterEmail, }); + /// Deserialises from the `GET /notifications/preferences` response body. factory NotificationPreferencesModel.fromJson(Map json) { return NotificationPreferencesModel( newFollowerPush: json['new_follower_push'] as bool? ?? true, @@ -80,6 +90,7 @@ class NotificationPreferencesModel extends NotificationPreferencesEntity { ); } + /// Serialises to the body format expected by `PATCH /notifications/preferences`. Map toJson() { return { 'new_follower_push': newFollowerPush, @@ -107,6 +118,7 @@ class NotificationPreferencesModel extends NotificationPreferencesEntity { }; } + /// Creates a model from a domain [entity], preserving all field values. factory NotificationPreferencesModel.fromEntity( NotificationPreferencesEntity entity, ) { @@ -136,6 +148,7 @@ class NotificationPreferencesModel extends NotificationPreferencesEntity { ); } + /// Converts this model to a pure domain [NotificationPreferencesEntity]. NotificationPreferencesEntity toDomain() => NotificationPreferencesEntity( newFollowerPush: newFollowerPush, newFollowerEmail: newFollowerEmail, diff --git a/lib/features/settings/data/models/privacy_settings_model.dart b/lib/features/settings/data/models/privacy_settings_model.dart index 727a384e..529dff8f 100644 --- a/lib/features/settings/data/models/privacy_settings_model.dart +++ b/lib/features/settings/data/models/privacy_settings_model.dart @@ -1,5 +1,10 @@ import '../../domain/entities/privacy_settings_entity.dart'; +/// Data-layer representation of [PrivacySettingsEntity]. +/// +/// Adds [fromJson] for deserialising the API response, [toPatch] for computing +/// a minimal PATCH body containing only changed fields, and [fromEntity]/[toDomain] +/// for converting between domain and data types. class PrivacySettingsModel extends PrivacySettingsEntity { const PrivacySettingsModel({ required super.isPrivate, @@ -9,6 +14,9 @@ class PrivacySettingsModel extends PrivacySettingsEntity { required super.showTopFansOnTracks, }); + /// Deserialises from the `GET /users/me/privacy-settings` response body. + /// + /// All fields default to safe values when absent from the API response. factory PrivacySettingsModel.fromJson(Map json) { return PrivacySettingsModel( isPrivate: json['is_private'] as bool? ?? false, @@ -21,6 +29,10 @@ class PrivacySettingsModel extends PrivacySettingsEntity { ); } + /// Produces a minimal patch body containing only fields that differ from [previous]. + /// + /// Sending only changed fields avoids overwriting server-side defaults for + /// fields the client has not explicitly set (e.g. `show_as_top_fan`). Map toPatch(PrivacySettingsModel previous) { final patch = {}; if (previous.receiveMessageFromAnyone != receiveMessageFromAnyone) { @@ -32,9 +44,13 @@ class PrivacySettingsModel extends PrivacySettingsEntity { if (previous.showAsTopFan != showAsTopFan) { patch['show_as_top_fan'] = showAsTopFan; } + if (previous.showTopFansOnTracks != showTopFansOnTracks) { + patch['show_top_fans_on_tracks'] = showTopFansOnTracks; + } return patch; } + /// Creates a model from a domain [entity], preserving all field values. factory PrivacySettingsModel.fromEntity(PrivacySettingsEntity entity) { return PrivacySettingsModel( isPrivate: entity.isPrivate, @@ -45,6 +61,7 @@ class PrivacySettingsModel extends PrivacySettingsEntity { ); } + /// Converts this model to a pure domain [PrivacySettingsEntity]. PrivacySettingsEntity toDomain() => PrivacySettingsEntity( isPrivate: isPrivate, receiveMessageFromAnyone: receiveMessageFromAnyone, diff --git a/lib/features/settings/data/repositories/settings_repo_impl.dart b/lib/features/settings/data/repositories/settings_repo_impl.dart index ce1933e9..3984dfe0 100644 --- a/lib/features/settings/data/repositories/settings_repo_impl.dart +++ b/lib/features/settings/data/repositories/settings_repo_impl.dart @@ -5,18 +5,27 @@ import 'package:rythmify/features/settings/domain/entities/notification_preferen import 'package:rythmify/features/settings/domain/entities/privacy_settings_entity.dart'; import 'package:rythmify/features/settings/domain/repositories/settings_repo_interface.dart'; +/// Concrete implementation of [SettingsRepoInterface]. +/// +/// Converts between domain entities and data models before delegating +/// to [SettingsRemoteDatasources]. The entity↔model conversions are +/// handled by the model factories ([fromEntity], [toDomain]). class SettingsRepoImpl implements SettingsRepoInterface { final SettingsRemoteDatasources _datasource; SettingsRepoImpl(this._datasource); @override + /// Fetches privacy settings and converts the model to a domain entity. Future getPrivacySettings() async { final model = await _datasource.getPrivacySettings(); return model.toDomain(); } @override + /// Computes the diff between [previous] and [updated], then PATCHes only changed fields. + /// + /// Returns [updated] immediately if no fields changed, avoiding a redundant network call. Future updatePrivacySettings( PrivacySettingsEntity previous, PrivacySettingsEntity updated, @@ -30,12 +39,14 @@ class SettingsRepoImpl implements SettingsRepoInterface { } @override + /// Fetches notification preferences and converts the model to a domain entity. Future getNotificationPreferences() async { final model = await _datasource.getNotificationPreferences(); return model.toDomain(); } @override + /// Converts [notificationPreferences] to a model, persists it, and returns the saved entity. Future updateNotificationPreferences( NotificationPreferencesEntity notificationPreferences, ) async { @@ -46,6 +57,7 @@ class SettingsRepoImpl implements SettingsRepoInterface { } @override + /// Permanently deletes the authenticated user's account. Future deleteMyAccount() async { await _datasource.deleteMyAccount(); } diff --git a/lib/features/settings/domain/entities/notification_preferences_entity.dart b/lib/features/settings/domain/entities/notification_preferences_entity.dart index 04411df3..1718be12 100644 --- a/lib/features/settings/domain/entities/notification_preferences_entity.dart +++ b/lib/features/settings/domain/entities/notification_preferences_entity.dart @@ -1,7 +1,17 @@ import 'package:equatable/equatable.dart'; +/// Controls who can send the user direct messages. +/// +/// - [everyone] — any Rythmify user can send a message. +/// - [followersOnly] — only users who follow the current user can message them. +/// - [nobody] — direct messages are disabled entirely. enum MessagesFrom { everyone, followersOnly, nobody } +/// Immutable entity representing all notification and messaging preferences for a user. +/// +/// Each field is a boolean toggle controlling whether a push notification or +/// email is sent for a specific event type. Use [copyWith] to derive updated +/// copies when the user changes a setting. class NotificationPreferencesEntity extends Equatable { final bool newFollowerPush; final bool newFollowerEmail; @@ -51,6 +61,7 @@ class NotificationPreferencesEntity extends Equatable { required this.newsletterEmail, }); + /// Returns a copy of this entity with only the provided fields replaced. NotificationPreferencesEntity copyWith({ bool? newFollowerPush, bool? newFollowerEmail, diff --git a/lib/features/settings/domain/entities/privacy_settings_entity.dart b/lib/features/settings/domain/entities/privacy_settings_entity.dart index 4f9e9e75..c888330a 100644 --- a/lib/features/settings/domain/entities/privacy_settings_entity.dart +++ b/lib/features/settings/domain/entities/privacy_settings_entity.dart @@ -1,10 +1,24 @@ import 'package:equatable/equatable.dart'; +/// Immutable entity representing a user's privacy configuration. +/// +/// Describes visibility and interaction rules for the account. +/// Use [copyWith] to derive updated copies; [PrivacySettingsModel.toPatch] +/// then computes only the changed fields for the PATCH request. class PrivacySettingsEntity extends Equatable { + /// Whether the account is private (followers must be approved). final bool isPrivate; + + /// Whether any user can send a direct message, or only followers. final bool receiveMessageFromAnyone; + + /// Whether the user's listening activity appears in the discovery feed. final bool showActivitiesInDiscovery; + + /// Whether the user is shown as a top fan on artists' profiles. final bool showAsTopFan; + + /// Whether the user's top-fan badge appears on individual track pages. final bool showTopFansOnTracks; const PrivacySettingsEntity({ required this.isPrivate, @@ -14,6 +28,7 @@ class PrivacySettingsEntity extends Equatable { required this.showTopFansOnTracks, }); + /// Returns a copy of this entity with only the provided fields replaced. PrivacySettingsEntity copyWith({ bool? isPrivate, bool? receiveMessageFromAnyone, diff --git a/lib/features/settings/domain/repositories/settings_repo_interface.dart b/lib/features/settings/domain/repositories/settings_repo_interface.dart index e3fe80f7..aec8b2ad 100644 --- a/lib/features/settings/domain/repositories/settings_repo_interface.dart +++ b/lib/features/settings/domain/repositories/settings_repo_interface.dart @@ -1,15 +1,35 @@ import 'package:rythmify/features/settings/domain/entities/notification_preferences_entity.dart'; import 'package:rythmify/features/settings/domain/entities/privacy_settings_entity.dart'; +/// Abstract contract for settings-related data operations. +/// +/// Implemented by [SettingsRepoImpl], which converts between domain entities +/// and data models before delegating to [SettingsRemoteDatasources]. abstract class SettingsRepoInterface { + /// Fetches the current user's privacy settings from the backend. Future getPrivacySettings(); + + /// Applies changed privacy fields to the backend. + /// + /// [previous] is diffed against [updated] by [PrivacySettingsModel.toPatch] + /// so that only modified fields are sent in the PATCH body. Returns the + /// server-confirmed entity, or [updated] if no fields changed. Future updatePrivacySettings( PrivacySettingsEntity previous, PrivacySettingsEntity updated, ); + + /// Fetches the current user's notification and messaging preferences. Future getNotificationPreferences(); + + /// Persists [notificationPreferences] to the backend and returns the saved entity. Future updateNotificationPreferences( NotificationPreferencesEntity notificationPreferences, ); + + /// Permanently deletes the authenticated user's account. + /// + /// This operation is irreversible. The caller is responsible for signing + /// the user out and clearing local state after this resolves. Future deleteMyAccount(); } diff --git a/lib/features/settings/presentation/pages/app_icon_screen.dart b/lib/features/settings/presentation/pages/app_icon_screen.dart index f91b5d52..e4b7184c 100644 --- a/lib/features/settings/presentation/pages/app_icon_screen.dart +++ b/lib/features/settings/presentation/pages/app_icon_screen.dart @@ -1,5 +1,4 @@ import 'package:dynamic_app_icon_flutter_plus/dynamic_app_icon_flutter_plus.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -103,7 +102,7 @@ class _AppIconScreenState extends ConsumerState { } Future _selectIcon(_IconOption option) async { - if (option.isPremium && !ref.read(premiumProvider).isPremium) { + if (option.isPremium && !ref.read(isPremiumProvider)) { context.push('/library/settings/basic-settings/app-icons/premium-apps'); return; } @@ -130,6 +129,7 @@ class _AppIconScreenState extends ConsumerState { @override Widget build(BuildContext context) { final premiumState = ref.watch(premiumProvider); + final userIsPremium = ref.watch(isPremiumProvider); if (!premiumState.isInitialized) { return Scaffold( @@ -143,19 +143,6 @@ class _AppIconScreenState extends ConsumerState { ); } - final sub = premiumState.subscription; - debugPrint('🔑 [AppIcon] isInitialized=${premiumState.isInitialized}'); - debugPrint('🔑 [AppIcon] isPremium=${premiumState.isPremium}'); - debugPrint('🔑 [AppIcon] error=${premiumState.error ?? 'none'}'); - debugPrint('🔑 [AppIcon] subscription=${sub == null ? 'NULL (no subscription)' : 'present'}'); - if (sub != null) { - debugPrint('🔑 [AppIcon] status="${sub.status}" isActive=${sub.isActive}'); - debugPrint('🔑 [AppIcon] plan.name="${sub.plan.name}" plan.isPremium=${sub.plan.isPremium}'); - debugPrint('🔑 [AppIcon] autoRenew=${sub.autoRenew} endDate=${sub.endDate}'); - } - - final userIsPremium = premiumState.isPremium; - return Theme( data: Theme.of(context).copyWith( splashColor: const Color(0xFF3A3A3A), diff --git a/lib/features/settings/presentation/pages/social_settings_screen.dart b/lib/features/settings/presentation/pages/social_settings_screen.dart index 862f8ab6..ab9bd9a6 100644 --- a/lib/features/settings/presentation/pages/social_settings_screen.dart +++ b/lib/features/settings/presentation/pages/social_settings_screen.dart @@ -87,6 +87,18 @@ class SocialSettingsScreen extends ConsumerWidget { .save(data.copyWith(showAsTopFan: value)); }, ), + SwitchTileWidget( + key: const Key('show_top_fans_on_tracks_tile'), + title: 'Show Top Fans on my tracks', + subtitle: + 'Top Fans and First Fans leaderboards are visible on your uploaded tracks', + initSwitchValue: data.showTopFansOnTracks, + onSwitchChanged: (value) { + ref + .read(privacySettingsProvider.notifier) + .save(data.copyWith(showTopFansOnTracks: value)); + }, + ), ], ); }, diff --git a/lib/features/settings/presentation/providers/notification_prefs_notifier.dart b/lib/features/settings/presentation/providers/notification_prefs_notifier.dart index f14c1ff4..477483dd 100644 --- a/lib/features/settings/presentation/providers/notification_prefs_notifier.dart +++ b/lib/features/settings/presentation/providers/notification_prefs_notifier.dart @@ -2,19 +2,31 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:rythmify/features/settings/domain/entities/notification_preferences_entity.dart'; import 'package:rythmify/features/settings/presentation/providers/settings_providers.dart'; +/// Provides and manages the current user's notification preferences. +/// +/// Loads preferences on first access and keeps them in sync after [save] calls. final notificationPrefsProvider = AsyncNotifierProvider< NotificationPrefsNotifier, NotificationPreferencesEntity >(NotificationPrefsNotifier.new); +/// Notifier for notification preferences with optimistic UI updates. +/// +/// On [save], the state is immediately updated to the new value so the UI +/// reflects the change without waiting for the network. If the request fails, +/// the previous state is restored and the error is surfaced. class NotificationPrefsNotifier extends AsyncNotifier { @override + /// Fetches preferences from the repository on initial load. Future build() { return ref.watch(settingsRepositoryProvider).getNotificationPreferences(); } + /// Persists [updated] preferences and applies an optimistic local update. + /// + /// Reverts to the previous state and emits [AsyncError] if the request fails. Future save(NotificationPreferencesEntity updated) async { final previous = state; state = AsyncData(updated); diff --git a/lib/features/settings/presentation/providers/privacy_settings_notifier.dart b/lib/features/settings/presentation/providers/privacy_settings_notifier.dart index 27ae8b0e..6f75700e 100644 --- a/lib/features/settings/presentation/providers/privacy_settings_notifier.dart +++ b/lib/features/settings/presentation/providers/privacy_settings_notifier.dart @@ -2,17 +2,30 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:rythmify/features/settings/domain/entities/privacy_settings_entity.dart'; import 'package:rythmify/features/settings/presentation/providers/settings_providers.dart'; +/// Provides and manages the current user's privacy settings. +/// +/// Loads settings on first access. Consumers read this provider and call +/// [PrivacySettingsNotifier.save] to apply user changes. final privacySettingsProvider = AsyncNotifierProvider( PrivacySettingsNotifier.new, ); +/// Notifier for privacy settings with optimistic UI updates. +/// +/// [save] immediately applies the toggle in local state, then fires the PATCH. +/// The server response is intentionally NOT used to overwrite the local state — +/// the PATCH response may default absent fields to `true`, silently reverting +/// toggles the user just set. Errors are swallowed when the backend row does +/// not yet exist for the account. class PrivacySettingsNotifier extends AsyncNotifier { @override + /// Fetches privacy settings from the repository on initial load. Future build() { return ref.watch(settingsRepositoryProvider).getPrivacySettings(); } + /// Applies [updated] optimistically and persists only the changed fields. Future save(PrivacySettingsEntity updated) async { final previous = state; state = AsyncData(updated); diff --git a/test/features/messaging/data/datasources/datasource_implement_test.dart b/test/features/messaging/data/datasources/datasource_implement_test.dart new file mode 100644 index 00000000..b932dc58 --- /dev/null +++ b/test/features/messaging/data/datasources/datasource_implement_test.dart @@ -0,0 +1,747 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/messaging/data/datasources/api_endpoints.dart'; +import 'package:rythmify/features/messaging/data/datasources/datasource_implement.dart'; +import 'package:rythmify/features/messaging/data/models/sent_message_request_model.dart'; + +class MockDio extends Mock implements Dio {} + +Response _resp(dynamic data) => Response( + data: data, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ); + +DioException _dioEx(int statusCode) => DioException( + requestOptions: RequestOptions(path: ''), + response: Response( + statusCode: statusCode, + requestOptions: RequestOptions(path: ''), + ), + type: DioExceptionType.badResponse, + ); + +void main() { + late MockDio mockDio; + late DatasourceImplement ds; + + final tConvJson = { + 'id': 'c1', + 'participant': {'id': 'u2', 'display_name': 'Alice'}, + 'last_message': {'body': 'Hey', 'created_at': '2024-01-01T00:00:00.000Z'}, + 'unread_count': 0, + }; + + final tMsgJson = { + 'id': 'm1', + 'conversation_id': 'c1', + 'sender_id': 'u1', + 'body': 'Hello', + 'is_read': false, + 'created_at': '2024-01-01T00:00:00.000Z', + }; + + final tUserJson = {'id': 'u2', 'display_name': 'Alice'}; + + setUp(() { + mockDio = MockDio(); + ds = DatasourceImplement(dio: mockDio); + }); + + // ─── getConversations ──────────────────────────────────────────────────────── + + group('getConversations', () { + test('returns list of conversations on success', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': {'items': [tConvJson]}, + })); + + final result = await ds.getConversations(); + + expect(result, hasLength(1)); + expect(result.first.conversationId, 'c1'); + expect(result.first.participantName, 'Alice'); + }); + + test('returns empty list when items is empty', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': {'items': []}, + })); + + expect(await ds.getConversations(), isEmpty); + }); + + test('throws when response data is not a map', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp('bad')); + + expect(() => ds.getConversations(), throwsException); + }); + }); + + // ─── getMessages ───────────────────────────────────────────────────────────── + + group('getMessages', () { + test('parses messages when data field is a Map', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': { + 'messages': [tMsgJson], + 'pagination': {'total': 1}, + }, + })); + + final (msgs, total) = await ds.getMessages(conversationId: 'c1'); + + expect(msgs, hasLength(1)); + expect(msgs.first.messageId, 'm1'); + expect(total, 1); + }); + + test('parses messages when data field is a List', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': [tMsgJson], + 'pagination': {'total': 1}, + })); + + final (msgs, total) = await ds.getMessages(conversationId: 'c1'); + + expect(msgs, hasLength(1)); + expect(total, 1); + }); + + test('returns empty list and zero total when data field is null', () async { + when(() => mockDio.get(any())) + .thenAnswer((_) async => _resp({'data': null})); + + final (msgs, total) = await ds.getMessages(conversationId: 'c1'); + + expect(msgs, isEmpty); + expect(total, 0); + }); + + test('uses pagination.total from map when present', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': { + 'messages': [tMsgJson], + 'pagination': {'total': 99}, + }, + })); + + final (_, total) = await ds.getMessages(conversationId: 'c1'); + + expect(total, 99); + }); + + test('falls back to message count when pagination absent', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': {'messages': [tMsgJson]}, + })); + + final (_, total) = await ds.getMessages(conversationId: 'c1'); + + expect(total, 1); + }); + + test('uses top-level pagination when data is a List', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': [tMsgJson], + 'pagination': {'total': 10}, + })); + + final (_, total) = await ds.getMessages(conversationId: 'c1'); + + expect(total, 10); + }); + + test('throws when response data is not a map', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp('bad')); + + expect(() => ds.getMessages(conversationId: 'c1'), throwsException); + }); + }); + + // ─── sendMessage ───────────────────────────────────────────────────────────── + + group('sendMessage', () { + test('returns parsed MessageModel on success', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _resp({'data': tMsgJson})); + + final req = SentMessageRequestModel(body: 'Hi'); + final result = await ds.sendMessage( + conversationId: 'c1', + requestContent: req, + ); + + expect(result.messageId, 'm1'); + expect(result.body, 'Hello'); + }); + + test('calls the correct endpoint', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _resp({'data': tMsgJson})); + + await ds.sendMessage( + conversationId: 'c1', + requestContent: SentMessageRequestModel(body: 'Hi'), + ); + + verify(() => mockDio.post( + ApiEndPoints.sendMessage('c1'), + data: any(named: 'data'), + )).called(1); + }); + }); + + // ─── newConversation ───────────────────────────────────────────────────────── + + group('newConversation', () { + test('returns conversation directly when response includes conversation key', + () async { + when(() => mockDio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _resp({ + 'data': {'conversation': tConvJson}, + })); + + final result = await ds.newConversation(participantId: 'u2'); + + expect(result.conversationId, 'c1'); + expect(result.participantId, 'u2'); + }); + + test('falls back to getConversations when response has no conversation key', + () async { + when(() => mockDio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _resp({'data': {}})); + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': {'items': [tConvJson]}, + })); + + final result = await ds.newConversation(participantId: 'u2'); + + expect(result.participantId, 'u2'); + }); + + test('throws when fallback finds no matching participant', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _resp({'data': {}})); + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': {'items': []}, + })); + + expect( + () => ds.newConversation(participantId: 'ghost-user'), + throwsException, + ); + }); + + test('includes body in request when provided', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _resp({ + 'data': {'conversation': tConvJson}, + })); + + await ds.newConversation(participantId: 'u2', body: 'Hello!'); + + final captured = verify(() => mockDio.post( + any(), + data: captureAny(named: 'data'), + )).captured; + expect((captured.single as Map)['body'], 'Hello!'); + }); + + test('includes track resource when trackId provided', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _resp({ + 'data': {'conversation': tConvJson}, + })); + + await ds.newConversation(participantId: 'u2', trackId: 't1'); + + final captured = verify(() => mockDio.post( + any(), + data: captureAny(named: 'data'), + )).captured; + expect((captured.single as Map)['resource'], + {'type': 'track', 'id': 't1'}); + }); + + test('includes playlist resource when playlistId provided', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _resp({ + 'data': {'conversation': tConvJson}, + })); + + await ds.newConversation(participantId: 'u2', playlistId: 'pl1'); + + final captured = verify(() => mockDio.post( + any(), + data: captureAny(named: 'data'), + )).captured; + expect((captured.single as Map)['resource'], + {'type': 'playlist', 'id': 'pl1'}); + }); + }); + + // ─── ensureConversation ─────────────────────────────────────────────────────── + + group('ensureConversation', () { + test('returns conversation on success', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _resp({ + 'data': {'conversation': tConvJson}, + })); + + final result = await ds.ensureConversation(participantId: 'u2'); + + expect(result.conversationId, 'c1'); + verify(() => mockDio.post( + ApiEndPoints.ensureConversation, + data: any(named: 'data'), + )).called(1); + }); + }); + + // ─── getUnreadCount ─────────────────────────────────────────────────────────── + + group('getUnreadCount', () { + test('returns unread count on success', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': {'unread_count': 5}, + })); + + expect(await ds.getUnreadCount(), 5); + }); + + test('returns 0 when count is zero', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': {'unread_count': 0}, + })); + + expect(await ds.getUnreadCount(), 0); + }); + }); + + // ─── blockUser ──────────────────────────────────────────────────────────────── + + group('blockUser', () { + test('completes without error', () async { + when(() => mockDio.post(any())).thenAnswer((_) async => _resp(null)); + + await expectLater(ds.blockUser(userId: 'u2'), completes); + verify(() => mockDio.post(ApiEndPoints.blockUser('u2'))).called(1); + }); + }); + + // ─── unBlockUser ───────────────────────────────────────────────────────────── + + group('unBlockUser', () { + test('completes without error', () async { + when(() => mockDio.delete(any())).thenAnswer((_) async => _resp(null)); + + await expectLater(ds.unBlockUser(userId: 'u2'), completes); + verify(() => mockDio.delete(ApiEndPoints.unBlockUser('u2'))).called(1); + }); + }); + + // ─── markMessagesAsRead ─────────────────────────────────────────────────────── + + group('markMessagesAsRead', () { + test('completes on success', () async { + when(() => mockDio.patch(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _resp(null)); + + await expectLater( + ds.markMessagesAsRead(conversationId: 'c1', messageId: 'm1'), + completes, + ); + }); + + test('swallows 409 DioException silently', () async { + when(() => mockDio.patch(any(), data: any(named: 'data'))) + .thenThrow(_dioEx(409)); + + await expectLater( + ds.markMessagesAsRead(conversationId: 'c1', messageId: 'm1'), + completes, + ); + }); + + test('rethrows non-409 DioException', () async { + when(() => mockDio.patch(any(), data: any(named: 'data'))) + .thenThrow(_dioEx(500)); + + expect( + () => ds.markMessagesAsRead(conversationId: 'c1', messageId: 'm1'), + throwsA(isA()), + ); + }); + + test('sends correct endpoint and payload', () async { + when(() => mockDio.patch(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _resp(null)); + + await ds.markMessagesAsRead(conversationId: 'c1', messageId: 'm1'); + + verify(() => mockDio.patch( + ApiEndPoints.markMessagesAsRead('c1', 'm1'), + data: {'is_read': true}, + )).called(1); + }); + }); + + // ─── getFollowings ──────────────────────────────────────────────────────────── + + group('getFollowings', () { + test('returns list of potential conversations', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': {'items': [tUserJson]}, + })); + + final result = await ds.getFollowings('me'); + + expect(result, hasLength(1)); + expect(result.first.participantId, 'u2'); + expect(result.first.participantName, 'Alice'); + }); + + test('returns empty list when items is empty', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': {'items': []}, + })); + + expect(await ds.getFollowings('me'), isEmpty); + }); + + test('propagates DioException', () async { + when(() => mockDio.get(any())).thenThrow(_dioEx(401)); + + expect(() => ds.getFollowings('me'), throwsA(isA())); + }); + }); + + // ─── getSearchedUsers ───────────────────────────────────────────────────────── + + group('getSearchedUsers', () { + test('returns matching users', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': {'users': [tUserJson]}, + })); + + final result = await ds.getSearchedUsers('ali'); + + expect(result, hasLength(1)); + expect(result.first.participantId, 'u2'); + }); + + test('returns empty list when no users match', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': {'users': []}, + })); + + expect(await ds.getSearchedUsers('zzz'), isEmpty); + }); + + test('propagates DioException', () async { + when(() => mockDio.get(any())).thenThrow(_dioEx(500)); + + expect( + () => ds.getSearchedUsers('ali'), + throwsA(isA()), + ); + }); + }); + + // ─── isBlocked ──────────────────────────────────────────────────────────────── + + group('isBlocked', () { + test('returns true when is_blocking is true', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': {'is_blocking': true, 'is_blocked_by': false}, + })); + + expect(await ds.isBlocked('u2'), true); + }); + + test('returns false when is_blocking is false', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': {'is_blocking': false, 'is_blocked_by': false}, + })); + + expect(await ds.isBlocked('u2'), false); + }); + + test('throws when response data is not a map', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp('bad')); + + expect(() => ds.isBlocked('u2'), throwsException); + }); + }); + + // ─── isBlockedBy ───────────────────────────────────────────────────────────── + + group('isBlockedBy', () { + test('returns true when is_blocked_by is true', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': {'is_blocking': false, 'is_blocked_by': true}, + })); + + expect(await ds.isBlockedBy('u2'), true); + }); + + test('returns false when is_blocked_by is false', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': {'is_blocking': false, 'is_blocked_by': false}, + })); + + expect(await ds.isBlockedBy('u2'), false); + }); + + test('throws when response data is not a map', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp('bad')); + + expect(() => ds.isBlockedBy('u2'), throwsException); + }); + }); + + // ─── getEmbeds ──────────────────────────────────────────────────────────────── + + group('getEmbeds', () { + group('track', () { + test('returns SharedEmbedModels from liked tracks', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': { + 'items': [ + { + 'track_id': 't1', + 'title': 'Track One', + 'cover_image': 'http://img', + } + ], + }, + })); + + final result = await ds.getEmbeds('me', 'track'); + + expect(result, hasLength(1)); + expect(result.first.embedId, 't1'); + expect(result.first.embedType, 'track'); + expect(result.first.embedName, 'Track One'); + }); + + test('falls back to id field when track_id absent', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': { + 'items': [ + {'id': 't2', 'title': 'Track Two', 'cover_image': null} + ], + }, + })); + + final result = await ds.getEmbeds('me', 'track'); + + expect(result.first.embedId, 't2'); + }); + }); + + group('playlist', () { + test('merges liked and created playlists and deduplicates by id', + () async { + final likedItems = [ + {'id': 'p1', 'name': 'Liked One', 'cover_image': null}, + {'id': 'p2', 'name': 'Shared', 'cover_image': null}, + ]; + final createdItems = [ + {'id': 'p2', 'name': 'Shared', 'cover_image': null}, + {'id': 'p3', 'name': 'Created One', 'cover_image': null}, + ]; + + when(() => mockDio.get(any())).thenAnswer((inv) async { + final url = inv.positionalArguments.first as String; + if (url.contains('liked-playlists')) { + return _resp({'data': {'items': likedItems}}); + } + return _resp({'data': {'items': createdItems}}); + }); + + final result = await ds.getEmbeds('me', 'playlist'); + + expect(result, hasLength(3)); + expect(result.map((e) => e.embedId), containsAll(['p1', 'p2', 'p3'])); + }); + + test('skips items missing id or name', () async { + final items = [ + {'id': 'p1', 'name': 'Valid', 'cover_image': null}, + {'name': 'No ID', 'cover_image': null}, + {'id': 'p3', 'cover_image': null}, + ]; + + when(() => mockDio.get(any())) + .thenAnswer((_) async => _resp({'data': {'items': items}})); + + final result = await ds.getEmbeds('me', 'playlist'); + + // Only 'p1' has both id and non-empty name; 'p3' has empty name + expect(result.map((e) => e.embedId), contains('p1')); + expect(result.any((e) => e.embedId == 'p3'), false); + }); + + test('returns empty list when both sources are empty', () async { + when(() => mockDio.get(any())) + .thenAnswer((_) async => _resp({'data': {'items': []}})); + + expect(await ds.getEmbeds('me', 'playlist'), isEmpty); + }); + }); + + group('album', () { + test('returns SharedEmbedModels from liked albums', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': { + 'items': [ + { + 'playlist_id': 'a1', + 'name': 'Album One', + 'cover_image': null, + } + ], + }, + })); + + final result = await ds.getEmbeds('me', 'album'); + + expect(result, hasLength(1)); + expect(result.first.embedId, 'a1'); + expect(result.first.embedType, 'album'); + }); + }); + + test('returns empty list for unknown embedType', () async { + final result = await ds.getEmbeds('me', 'video'); + expect(result, isEmpty); + }); + }); + + // ─── getTrackDetails ───────────────────────────────────────────────────────── + + group('getTrackDetails', () { + test('parses track with artist_name field', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': { + 'id': 't1', + 'title': 'My Track', + 'artist_name': 'DJ One', + 'cover_image': 'http://img', + }, + })); + + final result = await ds.getTrackDetails('t1'); + + expect(result.embedId, 't1'); + expect(result.embedName, 'My Track'); + expect(result.artistName, 'DJ One'); + expect(result.thumbnailUrl, 'http://img'); + }); + + test('falls back to artist field when artist_name is absent', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': { + 'id': 't1', + 'title': 'My Track', + 'artist': 'DJ Two', + 'artwork_url': 'http://art', + }, + })); + + final result = await ds.getTrackDetails('t1'); + + expect(result.artistName, 'DJ Two'); + expect(result.thumbnailUrl, 'http://art'); + }); + + test('falls back to artists field when both artist_name and artist absent', + () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': { + 'id': 't1', + 'title': 'Collab', + 'artists': 'Artist A & Artist B', + 'cover_image': null, + }, + })); + + final result = await ds.getTrackDetails('t1'); + + expect(result.artistName, 'Artist A & Artist B'); + }); + + test('calls correct endpoint', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': { + 'id': 't1', + 'title': 'Track', + 'cover_image': null, + }, + })); + + await ds.getTrackDetails('t1'); + + verify(() => mockDio.get(ApiEndPoints.getTrackDetails('t1'))).called(1); + }); + }); + + // ─── getPlaylistDetails ─────────────────────────────────────────────────────── + + group('getPlaylistDetails', () { + test('returns playlist embed using playlist_id from response', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': { + 'playlist_id': 'pl1', + 'name': 'My Playlist', + 'owner_name': 'Alice', + 'cover_image': 'http://img', + }, + })); + + final result = await ds.getPlaylistDetails('pl1', 'playlist'); + + expect(result.embedId, 'pl1'); + expect(result.embedName, 'My Playlist'); + expect(result.artistName, 'Alice'); + expect(result.embedType, 'playlist'); + }); + + test('falls back to playlistId param when playlist_id absent in response', + () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': { + 'name': 'My Album', + 'owner_name': 'Bob', + 'cover_image': null, + }, + })); + + final result = await ds.getPlaylistDetails('a1', 'album'); + + expect(result.embedId, 'a1'); + expect(result.embedType, 'album'); + }); + + test('calls correct endpoint', () async { + when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + 'data': { + 'playlist_id': 'pl1', + 'name': 'P', + 'owner_name': 'X', + 'cover_image': null, + }, + })); + + await ds.getPlaylistDetails('pl1', 'playlist'); + + verify(() => mockDio.get(ApiEndPoints.getPlaylistDetails('pl1'))) + .called(1); + }); + }); +} From 08783c2defcec40c3c7d07d3dabb0ab25c0eca60 Mon Sep 17 00:00:00 2001 From: RanaElgharabawy Date: Sat, 2 May 2026 00:48:22 +0300 Subject: [PATCH 6/7] unit test docs --- .../datasources/datasource_implement_test.dart | 9 +++++++++ .../data/models/conversation_model_test.dart | 7 +++++++ .../messaging/data/models/message_model_test.dart | 4 ++++ .../models/potential_conversation_model_test.dart | 4 ++++ .../models/sent_message_request_model_test.dart | 5 +++++ .../data/models/shared_embed_model_test.dart | 4 ++++ .../repositories/repository_implement_test.dart | 6 ++++++ .../domain/entities/conversation_test.dart | 4 ++++ .../messaging/domain/entities/message_test.dart | 4 ++++ .../entities/potential_conversation_test.dart | 4 ++++ .../domain/entities/shared_embed_test.dart | 4 ++++ .../domain/usecases/messaging_usecases_test.dart | 6 ++++++ .../providers/block_unblock_notifier_test.dart | 5 +++++ .../providers/mark_as_read_notifier_test.dart | 7 +++++++ .../providers/messages_notifier_test.dart | 8 ++++++++ .../providers/messaging_providers_wiring_test.dart | 6 ++++++ .../providers/send_message_notifier_test.dart | 9 +++++++++ .../notification_datasources_impl_test.dart | 6 ++++++ .../data/models/notification_model_test.dart | 10 ++++++++++ .../repositories/notifications_repo_impl_test.dart | 5 +++++ .../domain/entities/notification_entity_test.dart | 5 +++++ .../usecases/notification_usecases_test.dart | 8 ++++++++ .../providers/follow_state_notifier_test.dart | 6 ++++++ .../notification_providers_wiring_test.dart | 10 ++++++++++ .../providers/notifications_notifier_test.dart | 14 ++++++++++++++ .../datasources/settings_datasource_impl_test.dart | 8 ++++++++ .../notification_preferences_model_test.dart | 9 +++++++++ .../data/models/privacy_settings_model_test.dart | 6 ++++++ .../data/repositories/settings_repo_impl_test.dart | 9 +++++++++ .../notification_preferences_entity_test.dart | 7 +++++++ .../entities/privacy_settings_entity_test.dart | 7 +++++++ .../notification_prefs_notifier_test.dart | 9 +++++++++ .../providers/privacy_settings_notifier_test.dart | 10 ++++++++++ 33 files changed, 225 insertions(+) diff --git a/test/features/messaging/data/datasources/datasource_implement_test.dart b/test/features/messaging/data/datasources/datasource_implement_test.dart index b932dc58..c5c000ea 100644 --- a/test/features/messaging/data/datasources/datasource_implement_test.dart +++ b/test/features/messaging/data/datasources/datasource_implement_test.dart @@ -5,14 +5,17 @@ import 'package:rythmify/features/messaging/data/datasources/api_endpoints.dart' import 'package:rythmify/features/messaging/data/datasources/datasource_implement.dart'; import 'package:rythmify/features/messaging/data/models/sent_message_request_model.dart'; +/// Mock Dio client used to stub HTTP responses in [DatasourceImplement] tests. class MockDio extends Mock implements Dio {} +/// Creates a successful [Response] wrapping [data] with HTTP 200. Response _resp(dynamic data) => Response( data: data, statusCode: 200, requestOptions: RequestOptions(path: ''), ); +/// Creates a [DioException] with the given [statusCode] for error-path testing. DioException _dioEx(int statusCode) => DioException( requestOptions: RequestOptions(path: ''), response: Response( @@ -22,6 +25,12 @@ DioException _dioEx(int statusCode) => DioException( type: DioExceptionType.badResponse, ); +/// Tests for [DatasourceImplement] — the Dio-backed HTTP datasource. +/// +/// All HTTP calls are stubbed via [MockDio]. Covers response parsing for both +/// Map and List data shapes, pagination fallbacks, 409 swallowing in +/// [markMessagesAsRead], embed deduplication in [getEmbeds], artist-name +/// fallbacks in [getTrackDetails], and error propagation paths. void main() { late MockDio mockDio; late DatasourceImplement ds; diff --git a/test/features/messaging/data/models/conversation_model_test.dart b/test/features/messaging/data/models/conversation_model_test.dart index 8b10d820..45d7af71 100644 --- a/test/features/messaging/data/models/conversation_model_test.dart +++ b/test/features/messaging/data/models/conversation_model_test.dart @@ -2,6 +2,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rythmify/features/messaging/data/models/conversation_model.dart'; import 'package:rythmify/features/messaging/domain/entities/conversation.dart'; +/// Builds a minimal conversation JSON payload for use in [ConversationModel.fromJson] tests. +/// +/// All fields have safe defaults; pass overrides to exercise specific branches. Map _buildJson({ String id = 'conv-1', Map? participant, @@ -17,6 +20,10 @@ Map _buildJson({ }; } +/// Tests for [ConversationModel] JSON serialisation and deserialisation. +/// +/// Covers [ConversationModel.fromJson] field parsing, avatar fallback chain, +/// embed-type preview generation, null/missing-key handling, and [toJson] round-trips. void main() { final tDate = DateTime.parse('2024-06-01T12:00:00.000Z'); diff --git a/test/features/messaging/data/models/message_model_test.dart b/test/features/messaging/data/models/message_model_test.dart index 04e2784c..a4b620f5 100644 --- a/test/features/messaging/data/models/message_model_test.dart +++ b/test/features/messaging/data/models/message_model_test.dart @@ -2,6 +2,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rythmify/features/messaging/data/models/message_model.dart'; import 'package:rythmify/features/messaging/domain/entities/message.dart'; +/// Tests for [MessageModel] JSON serialisation and deserialisation. +/// +/// Covers [MessageModel.fromJson] field parsing, embed fields, isRead flag, +/// null body handling, [toJson] serialisation, and fromJson→toJson round-trips. void main() { final tDate = DateTime.parse('2024-06-01T12:00:00.000Z'); diff --git a/test/features/messaging/data/models/potential_conversation_model_test.dart b/test/features/messaging/data/models/potential_conversation_model_test.dart index c4e7f4ba..7c5e8b33 100644 --- a/test/features/messaging/data/models/potential_conversation_model_test.dart +++ b/test/features/messaging/data/models/potential_conversation_model_test.dart @@ -2,6 +2,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rythmify/features/messaging/data/models/potential_conversation_model.dart'; import 'package:rythmify/features/messaging/domain/entities/potential_conversation.dart'; +/// Tests for [PotentialConversationModel.fromJson]. +/// +/// Covers the `id`/`user_id` fallback, optional field defaults, and +/// the intentional omission of the `location` field (pending backend support). void main() { group('PotentialConversationModel', () { group('fromJson', () { diff --git a/test/features/messaging/data/models/sent_message_request_model_test.dart b/test/features/messaging/data/models/sent_message_request_model_test.dart index 7b9ee9fd..4f1c8176 100644 --- a/test/features/messaging/data/models/sent_message_request_model_test.dart +++ b/test/features/messaging/data/models/sent_message_request_model_test.dart @@ -1,6 +1,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rythmify/features/messaging/data/models/sent_message_request_model.dart'; +/// Tests for [SentMessageRequestModel.toJson]. +/// +/// Verifies that the correct JSON keys are produced for text-only messages, +/// track embeds, playlist embeds, album embeds (serialised as `playlist` type), +/// combined body+embed messages, and the all-null edge case. void main() { group('SentMessageRequestModel', () { group('toJson', () { diff --git a/test/features/messaging/data/models/shared_embed_model_test.dart b/test/features/messaging/data/models/shared_embed_model_test.dart index f6ffc116..ebdf7284 100644 --- a/test/features/messaging/data/models/shared_embed_model_test.dart +++ b/test/features/messaging/data/models/shared_embed_model_test.dart @@ -2,6 +2,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rythmify/features/messaging/data/models/shared_embed_model.dart'; import 'package:rythmify/features/messaging/domain/entities/shared_embed.dart'; +/// Tests for [SharedEmbedModel] construction and [SharedEmbedModel.fromJson]. +/// +/// Verifies field parsing for the three embed types (track, playlist, album), +/// optional field nullability, and the [SharedEmbed] inheritance relationship. void main() { group('SharedEmbedModel', () { group('fromJson', () { diff --git a/test/features/messaging/data/repositories/repository_implement_test.dart b/test/features/messaging/data/repositories/repository_implement_test.dart index 53e5fe09..def0426b 100644 --- a/test/features/messaging/data/repositories/repository_implement_test.dart +++ b/test/features/messaging/data/repositories/repository_implement_test.dart @@ -8,8 +8,14 @@ import 'package:rythmify/features/messaging/data/models/sent_message_request_mod import 'package:rythmify/features/messaging/data/models/shared_embed_model.dart'; import 'package:rythmify/features/messaging/data/repositories/repository_implement.dart'; +/// Mock for [DatasourceInterface] used to isolate [RepositoryImplement] under test. class MockDatasourceInterface extends Mock implements DatasourceInterface {} +/// Tests for [RepositoryImplement]. +/// +/// Each group verifies that the repository delegates the correct parameters to +/// [DatasourceInterface] without adding any logic of its own, and that the +/// [SentMessageRequestModel] is constructed correctly from raw domain arguments. void main() { late RepositoryImplement repo; late MockDatasourceInterface mockDatasource; diff --git a/test/features/messaging/domain/entities/conversation_test.dart b/test/features/messaging/domain/entities/conversation_test.dart index 18409d5f..b5824b1e 100644 --- a/test/features/messaging/domain/entities/conversation_test.dart +++ b/test/features/messaging/domain/entities/conversation_test.dart @@ -1,6 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rythmify/features/messaging/domain/entities/conversation.dart'; +/// Tests for the [Conversation] domain entity. +/// +/// Verifies field storage, optional [participantAvatar] nullability, +/// zero [unReadCount], and [copyWith] immutability behaviour. void main() { final tDate = DateTime(2024, 6, 1, 10, 30); diff --git a/test/features/messaging/domain/entities/message_test.dart b/test/features/messaging/domain/entities/message_test.dart index 8d1ced68..3753732f 100644 --- a/test/features/messaging/domain/entities/message_test.dart +++ b/test/features/messaging/domain/entities/message_test.dart @@ -1,6 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rythmify/features/messaging/domain/entities/message.dart'; +/// Tests for the [Message] domain entity. +/// +/// Verifies required and optional field storage, embed fields, +/// and [copyWith] immutability including toggling [isRead] in both directions. void main() { final tDate = DateTime(2024, 6, 1, 12, 0, 0); diff --git a/test/features/messaging/domain/entities/potential_conversation_test.dart b/test/features/messaging/domain/entities/potential_conversation_test.dart index b5358f70..cf2b08f0 100644 --- a/test/features/messaging/domain/entities/potential_conversation_test.dart +++ b/test/features/messaging/domain/entities/potential_conversation_test.dart @@ -1,6 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rythmify/features/messaging/domain/entities/potential_conversation.dart'; +/// Tests for the [PotentialConversation] domain entity. +/// +/// Verifies required field storage, optional field nullability, and +/// that all optional fields are stored when explicitly provided. void main() { group('PotentialConversation', () { test('stores required fields', () { diff --git a/test/features/messaging/domain/entities/shared_embed_test.dart b/test/features/messaging/domain/entities/shared_embed_test.dart index 62720d9a..be692318 100644 --- a/test/features/messaging/domain/entities/shared_embed_test.dart +++ b/test/features/messaging/domain/entities/shared_embed_test.dart @@ -1,6 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rythmify/features/messaging/domain/entities/shared_embed.dart'; +/// Tests for the [SharedEmbed] domain entity. +/// +/// Verifies required field storage, optional field nullability, and that +/// all three embed types (track, playlist, album) are accepted by the constructor. void main() { group('SharedEmbed', () { test('stores required fields', () { diff --git a/test/features/messaging/domain/usecases/messaging_usecases_test.dart b/test/features/messaging/domain/usecases/messaging_usecases_test.dart index e9e48803..a9fc819d 100644 --- a/test/features/messaging/domain/usecases/messaging_usecases_test.dart +++ b/test/features/messaging/domain/usecases/messaging_usecases_test.dart @@ -22,8 +22,14 @@ import 'package:rythmify/features/messaging/domain/usecases/send_message_usecase import 'package:rythmify/features/messaging/domain/usecases/start_conversation_usecase.dart'; import 'package:rythmify/features/messaging/domain/usecases/unblock_user_usecase.dart'; +/// Mock for [MessagingRepository] used to isolate all messaging use case tests. class MockMessagingRepository extends Mock implements MessagingRepository {} +/// Tests for all messaging use cases. +/// +/// Each use case is tested for correct parameter delegation, return value +/// propagation, and exception re-throwing. No business logic is expected inside +/// use cases — they are thin wrappers that call exactly one repository method. void main() { late MockMessagingRepository mockRepo; diff --git a/test/features/messaging/presentation/providers/block_unblock_notifier_test.dart b/test/features/messaging/presentation/providers/block_unblock_notifier_test.dart index 203aeac5..b9cc05f0 100644 --- a/test/features/messaging/presentation/providers/block_unblock_notifier_test.dart +++ b/test/features/messaging/presentation/providers/block_unblock_notifier_test.dart @@ -10,8 +10,13 @@ import 'package:rythmify/features/messaging/presentation/providers/repository_pr import 'package:rythmify/features/messaging/presentation/providers/un_block_notifier.dart'; import 'package:rythmify/features/messaging/presentation/providers/un_block_provider.dart'; +/// Mock for [MessagingRepository] injected via [repositoryprovider] override. class MockMessagingRepository extends Mock implements MessagingRepository {} +/// Tests for [BlockUserNotifier] and [UnblockNotifier]. +/// +/// Verifies initial loading state, delegation to [MessagingRepository], +/// and that the state returns to `false` after each operation completes. void main() { late MockMessagingRepository mockRepo; diff --git a/test/features/messaging/presentation/providers/mark_as_read_notifier_test.dart b/test/features/messaging/presentation/providers/mark_as_read_notifier_test.dart index 36e83bcc..bf8ff130 100644 --- a/test/features/messaging/presentation/providers/mark_as_read_notifier_test.dart +++ b/test/features/messaging/presentation/providers/mark_as_read_notifier_test.dart @@ -8,8 +8,15 @@ import 'package:rythmify/features/messaging/presentation/providers/mark_as_read_ import 'package:rythmify/features/messaging/presentation/providers/mark_as_read_provider.dart'; import 'package:rythmify/features/messaging/presentation/providers/repository_provider.dart'; +/// Mock for [MessagingRepository] injected via [repositoryprovider] override. class MockMessagingRepository extends Mock implements MessagingRepository {} +/// Tests for [MarkAsReadNotifier]. +/// +/// Verifies that [markRead] delegates the correct message and conversation IDs +/// to the repository, that the loading state resets to `false` on completion, +/// and that exceptions (including 409 "already read") are silently swallowed. + void main() { late MockMessagingRepository mockRepo; diff --git a/test/features/messaging/presentation/providers/messages_notifier_test.dart b/test/features/messaging/presentation/providers/messages_notifier_test.dart index 6a666e26..1ecee7a3 100644 --- a/test/features/messaging/presentation/providers/messages_notifier_test.dart +++ b/test/features/messaging/presentation/providers/messages_notifier_test.dart @@ -4,13 +4,21 @@ import 'package:rythmify/features/messaging/domain/entities/message.dart'; import 'package:rythmify/features/messaging/domain/repositories/messaging_repository.dart'; import 'package:rythmify/features/messaging/presentation/providers/messages_notifier.dart'; +/// Mock for [MessagingRepository] used to stub [getMessages] in notifier tests. class MockMessagingRepository extends Mock implements MessagingRepository {} +/// Tests for [MessagesState] and [MessagesNotifier]. +/// +/// Covers [MessagesState.copyWith] immutability, [loadInitial] for both the +/// ≤100 and >100 total-message paths, error state on exception, [loadMore] +/// prepending and [hasMore] updates, [refresh] resetting pagination, +/// [appendMessage] appending to local state, and [updateMessageRead] targeting. void main() { late MockMessagingRepository mockRepo; final tDate = DateTime(2024, 6, 1); + /// Creates a minimal [Message] with the given [id], optionally marking it read. Message makeMessage(String id, {bool isRead = false}) => Message( messageId: id, senderId: 'user-1', diff --git a/test/features/messaging/presentation/providers/messaging_providers_wiring_test.dart b/test/features/messaging/presentation/providers/messaging_providers_wiring_test.dart index 80cb83a1..ea9b505a 100644 --- a/test/features/messaging/presentation/providers/messaging_providers_wiring_test.dart +++ b/test/features/messaging/presentation/providers/messaging_providers_wiring_test.dart @@ -8,8 +8,14 @@ import 'package:rythmify/features/messaging/presentation/providers/is_blocked_by import 'package:rythmify/features/messaging/presentation/providers/is_blocked_provider.dart'; import 'package:rythmify/features/messaging/presentation/providers/repository_provider.dart'; +/// Mock for [MessagingRepository] injected via [repositoryprovider] override. class MockMessagingRepository extends Mock implements MessagingRepository {} +/// Tests for Riverpod provider wiring in the messaging feature. +/// +/// Verifies that [conversationProvider], [isBlockedProvider], and +/// [isBlockedByProvider] correctly wire to their use cases via +/// [repositoryprovider], and that results are propagated without modification. void main() { late MockMessagingRepository mockRepo; diff --git a/test/features/messaging/presentation/providers/send_message_notifier_test.dart b/test/features/messaging/presentation/providers/send_message_notifier_test.dart index dd06dae4..63e554a2 100644 --- a/test/features/messaging/presentation/providers/send_message_notifier_test.dart +++ b/test/features/messaging/presentation/providers/send_message_notifier_test.dart @@ -11,10 +11,19 @@ import 'package:rythmify/features/messaging/presentation/providers/send_message_ import 'package:rythmify/features/messaging/presentation/providers/send_message_provider.dart'; import 'package:rythmify/features/messaging/presentation/providers/socket_provider.dart'; +/// Mock for [MessagingRepository] injected via [repositoryprovider] override. class MockMessagingRepository extends Mock implements MessagingRepository {} +/// Mock for [DataSourcesSockets] injected via [socketProvider] override. class MockDataSourcesSockets extends Mock implements DataSourcesSockets {} +/// Tests for [SendMessageNotifier]. +/// +/// Covers the two sending paths: sending within an existing conversation +/// (delegates to [SendMessageUsecase], emits via socket, invalidates inbox) +/// and starting a new conversation (delegates to [StartConversationUsecase], +/// routes embedId to trackId or playlistId based on embedType). +/// Also tests [ensureConversation] state management. void main() { late MockMessagingRepository mockRepo; late MockDataSourcesSockets mockSocket; diff --git a/test/features/notifications/data/datasources/notification_datasources_impl_test.dart b/test/features/notifications/data/datasources/notification_datasources_impl_test.dart index 2a35c4f7..711301f5 100644 --- a/test/features/notifications/data/datasources/notification_datasources_impl_test.dart +++ b/test/features/notifications/data/datasources/notification_datasources_impl_test.dart @@ -4,8 +4,14 @@ import 'package:mocktail/mocktail.dart'; import 'package:rythmify/features/notifications/data/datasources/notification_datasources_impl.dart'; import 'package:rythmify/features/notifications/domain/entities/notification_entity.dart'; +/// Mock Dio client used to stub HTTP responses for [NotificationDatasourcesImpl] tests. class MockDio extends Mock implements Dio {} +/// Tests for [NotificationDatasourcesImpl]. +/// +/// Covers [getNotifications] filtering of [NotificationType.newPostByFollowed], +/// pagination metadata, [getUnreadCount], [markNotificationAsRead], +/// [getFollowStatus] boolean extraction, and [getTrackIdByCommentId] parsing. void main() { late NotificationDatasourcesImpl datasource; late MockDio mockDio; diff --git a/test/features/notifications/data/models/notification_model_test.dart b/test/features/notifications/data/models/notification_model_test.dart index 0bf96279..6786f7e9 100644 --- a/test/features/notifications/data/models/notification_model_test.dart +++ b/test/features/notifications/data/models/notification_model_test.dart @@ -2,6 +2,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rythmify/features/notifications/data/models/notification_model.dart'; import 'package:rythmify/features/notifications/domain/entities/notification_entity.dart'; +/// Builds a minimal notification JSON payload for [NotificationModel.fromJson] tests. +/// +/// Supports null-propagation syntax for optional fields so absent keys are +/// excluded from the map entirely, matching real API behaviour. Map _buildJson({ String id = 'notif-1', String type = 'follow', @@ -26,6 +30,12 @@ Map _buildJson({ }; } +/// Tests for [NotificationModel.fromJson]. +/// +/// Covers all [NotificationType] and [ResourceType] mappings, the actor ID +/// fallback chain (`actor.id` → `action_user_id` → empty string), display-name +/// fallback (`display_name` → `username` → "Unknown"), cover-image fallback +/// (`cover_image` → `cover_url`), and the [NotificationEntity] subtype check. void main() { group('NotificationModel.fromJson', () { group('notification types', () { diff --git a/test/features/notifications/data/repositories/notifications_repo_impl_test.dart b/test/features/notifications/data/repositories/notifications_repo_impl_test.dart index 67ff9da8..0c903679 100644 --- a/test/features/notifications/data/repositories/notifications_repo_impl_test.dart +++ b/test/features/notifications/data/repositories/notifications_repo_impl_test.dart @@ -5,9 +5,14 @@ import 'package:rythmify/features/notifications/data/models/notification_model.d import 'package:rythmify/features/notifications/data/repositories/notifications_repo_impl.dart'; import 'package:rythmify/features/notifications/domain/entities/notification_entity.dart'; +/// Mock for [NotificationRemoteDatasources] used to isolate [NotificationsRepoImpl]. class MockNotificationRemoteDatasources extends Mock implements NotificationRemoteDatasources {} +/// Tests for [NotificationsRepoImpl]. +/// +/// Verifies that all five repository methods are thin delegates to +/// [NotificationRemoteDatasources] with no parameter transformation. void main() { late NotificationsRepoImpl repo; late MockNotificationRemoteDatasources mockDatasource; diff --git a/test/features/notifications/domain/entities/notification_entity_test.dart b/test/features/notifications/domain/entities/notification_entity_test.dart index 360ee016..c170a96c 100644 --- a/test/features/notifications/domain/entities/notification_entity_test.dart +++ b/test/features/notifications/domain/entities/notification_entity_test.dart @@ -1,6 +1,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rythmify/features/notifications/domain/entities/notification_entity.dart'; +/// Tests for [NotificationEntity]. +/// +/// Covers default field values, [copyWith] immutability for [isRead] and +/// [isActorFollowed], preservation of optional fields through a copy, and +/// [Equatable] value equality via the [props] list. void main() { final tCreatedAt = DateTime(2024, 1, 15); diff --git a/test/features/notifications/domain/usecases/notification_usecases_test.dart b/test/features/notifications/domain/usecases/notification_usecases_test.dart index 20264bb3..d2b2a9cf 100644 --- a/test/features/notifications/domain/usecases/notification_usecases_test.dart +++ b/test/features/notifications/domain/usecases/notification_usecases_test.dart @@ -8,9 +8,11 @@ import 'package:rythmify/features/notifications/domain/usecases/get_track_id_by_ import 'package:rythmify/features/notifications/domain/usecases/get_unread_count_usecase.dart'; import 'package:rythmify/features/notifications/domain/usecases/mark_notification_as_read_usecase.dart'; +/// Mock for [NotificationsRepoInterface] used to isolate all notification use cases. class MockNotificationsRepo extends Mock implements NotificationsRepoInterface {} +/// Shared notification fixture used across multiple use case tests. final tNotification = NotificationEntity( id: 'n1', type: NotificationType.follow, @@ -20,6 +22,12 @@ final tNotification = NotificationEntity( createdAt: DateTime(2024, 1, 15), ); +/// Tests for all notification use cases. +/// +/// Covers [GetNotificationsUsecase], [GetUnreadCountUsecase], +/// [MarkNotificationAsReadUsecase], [GetFollowStatusUsecase], and +/// [GetTrackIdByCommentIdUsecase] — each verified for parameter delegation +/// and return value propagation with no added logic. void main() { late MockNotificationsRepo mockRepo; diff --git a/test/features/notifications/presentation/providers/follow_state_notifier_test.dart b/test/features/notifications/presentation/providers/follow_state_notifier_test.dart index 9a6bb320..2b9a244d 100644 --- a/test/features/notifications/presentation/providers/follow_state_notifier_test.dart +++ b/test/features/notifications/presentation/providers/follow_state_notifier_test.dart @@ -3,9 +3,15 @@ import 'package:mocktail/mocktail.dart'; import 'package:rythmify/features/notifications/domain/usecases/get_follow_status_usecase.dart'; import 'package:rythmify/features/notifications/presentation/providers/follow_state_provider.dart'; +/// Mock for [GetFollowStatusUsecase] used to isolate [FollowStateNotifier]. class MockGetFollowStatusUsecase extends Mock implements GetFollowStatusUsecase {} +/// Tests for [FollowStateNotifier]. +/// +/// Verifies the initial empty-map state, that [fetchFollowState] caches +/// the boolean result keyed by userId, and that multiple users can be tracked +/// independently in the same state map. void main() { late MockGetFollowStatusUsecase mockUsecase; late FollowStateNotifier notifier; diff --git a/test/features/notifications/presentation/providers/notification_providers_wiring_test.dart b/test/features/notifications/presentation/providers/notification_providers_wiring_test.dart index 89424957..6732231c 100644 --- a/test/features/notifications/presentation/providers/notification_providers_wiring_test.dart +++ b/test/features/notifications/presentation/providers/notification_providers_wiring_test.dart @@ -18,14 +18,18 @@ import 'package:rythmify/features/notifications/presentation/providers/repo_prov // Mocks // --------------------------------------------------------------------------- +/// Mock for [NotificationsRepoInterface] injected via [repoProvider] override. class MockNotificationsRepo extends Mock implements NotificationsRepoInterface {} +/// Mock for [ToggleCommentLikeUseCase] to avoid real network calls in wiring tests. class MockToggleCommentLikeUseCase extends Mock implements ToggleCommentLikeUseCase {} +/// Mock for [DataSourcesSockets] injected via [socketProvider] override. class MockDataSourcesSockets extends Mock implements DataSourcesSockets {} +/// Fake [AuthNotifier] that returns a fixed [AuthState] without real authentication. class MockAuthNotifier extends AuthNotifier with Mock { final AuthState _initial; MockAuthNotifier(this._initial); @@ -33,12 +37,18 @@ class MockAuthNotifier extends AuthNotifier with Mock { AuthState build() => _initial; } +/// Minimal [FollowStateNotifier] subclass that passes through to the real implementation. class FakeFollowStateNotifier extends FollowStateNotifier { FakeFollowStateNotifier(super.usecase); } // --------------------------------------------------------------------------- +/// Tests for provider factory wiring in the notifications feature. +/// +/// Verifies that [notificationsProvider] builds and receives socket callbacks, +/// follow-state updates, and comment-like toggles correctly when dependencies +/// are overridden with mocks. void main() { late MockNotificationsRepo mockRepo; late MockToggleCommentLikeUseCase mockToggleCommentLike; diff --git a/test/features/notifications/presentation/providers/notifications_notifier_test.dart b/test/features/notifications/presentation/providers/notifications_notifier_test.dart index 8cc2508b..962bf416 100644 --- a/test/features/notifications/presentation/providers/notifications_notifier_test.dart +++ b/test/features/notifications/presentation/providers/notifications_notifier_test.dart @@ -19,6 +19,7 @@ import 'package:rythmify/features/profile/domain/usecases/get_unfollow_user_usec // Mocks // --------------------------------------------------------------------------- +/// Mock use cases injected directly into [NotificationsNotifier] under test. class MockGetNotificationsUsecase extends Mock implements GetNotificationsUsecase {} @@ -35,6 +36,7 @@ class MockGetUnfollowUserUseCase extends Mock class MockToggleCommentLikeUseCase extends Mock implements ToggleCommentLikeUseCase {} +/// Mock for [NotificationsRepoInterface] used to stub follow-status lookups. class MockNotificationsRepo extends Mock implements NotificationsRepoInterface {} @@ -42,6 +44,8 @@ class MockNotificationsRepo extends Mock // Fake FollowStateNotifier — records calls without real I/O // --------------------------------------------------------------------------- +/// Test double for [FollowStateNotifier] that records [fetchFollowState] and +/// [setFollowing] calls for assertion without making real network requests. class FakeFollowStateNotifier extends FollowStateNotifier { final List fetchedIds = []; final List> setFollowingCalls = []; @@ -64,8 +68,10 @@ class FakeFollowStateNotifier extends FollowStateNotifier { // Fixtures // --------------------------------------------------------------------------- +/// Shared timestamp used across all notification fixtures. final tCreatedAt = DateTime(2024, 1, 15); +/// Fixture representing an unread follow notification. final tFollowNotif = NotificationEntity( id: 'n-follow', type: NotificationType.follow, @@ -84,17 +90,25 @@ final tLikeNotif = NotificationEntity( createdAt: tCreatedAt, ); +/// Builds a paginated notifications response record for use in mock stubs. ({List items, int unreadCount, bool hasNext}) _page( List items, { int unread = 0, bool hasNext = false, }) => (items: items, unreadCount: unread, hasNext: hasNext); +/// Returns an empty page response — no items, zero unread, no next page. ({List items, int unreadCount, bool hasNext}) _emptyPage() => (items: [], unreadCount: 0, hasNext: false); // --------------------------------------------------------------------------- +/// Tests for [NotificationsNotifier]. +/// +/// Covers initial fetch, [loadMore] pagination, [markAsRead] optimistic update, +/// [toggleFollow]/[toggleUnfollow] follow-state propagation, [onSocketNotification] +/// prepending live events, [onNotificationRead] marking read in local state, +/// and [toggleCommentLike] dispatching to [ToggleCommentLikeUseCase]. void main() { late MockGetNotificationsUsecase mockGetNotifications; late MockGetUnreadCountUsecase mockGetUnreadCount; diff --git a/test/features/settings/data/datasources/settings_datasource_impl_test.dart b/test/features/settings/data/datasources/settings_datasource_impl_test.dart index f5598f16..857c4fdd 100644 --- a/test/features/settings/data/datasources/settings_datasource_impl_test.dart +++ b/test/features/settings/data/datasources/settings_datasource_impl_test.dart @@ -7,8 +7,10 @@ import 'package:rythmify/features/settings/data/models/notification_preferences_ import 'package:rythmify/features/settings/data/models/privacy_settings_model.dart'; import 'package:rythmify/features/settings/domain/entities/notification_preferences_entity.dart'; +/// Mock for [ApiClient] — exposes a stubbed [Dio] instance via `.dio`. class MockApiClient extends Mock implements ApiClient {} +/// Mock Dio client returned by [MockApiClient.dio] for HTTP response stubbing. class MockDio extends Mock implements Dio {} const tPrivacyJson = { @@ -81,6 +83,12 @@ Response _notifResponse() => Response( data: {'data': tNotifJson}, ); +/// Tests for [SettingsDatasourcesImpl]. +/// +/// Covers GET and PATCH /users/me/privacy-settings (including null/non-Map +/// response fallbacks), GET and PATCH /notifications/preferences with full +/// [NotificationPreferencesModel] round-trip, and DELETE /users/me. +/// All HTTP calls are intercepted via [MockDio] returned by [MockApiClient.dio]. void main() { late MockApiClient mockApiClient; late MockDio mockDio; diff --git a/test/features/settings/data/models/notification_preferences_model_test.dart b/test/features/settings/data/models/notification_preferences_model_test.dart index f27aa2cb..fd557d57 100644 --- a/test/features/settings/data/models/notification_preferences_model_test.dart +++ b/test/features/settings/data/models/notification_preferences_model_test.dart @@ -2,6 +2,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rythmify/features/settings/data/models/notification_preferences_model.dart'; import 'package:rythmify/features/settings/domain/entities/notification_preferences_entity.dart'; +/// Full JSON payload with all fields explicitly set (push=false, email=true) +/// and `messages_from` as `followers_only`. Used for [fromJson] round-trip tests. const tFullJson = { 'new_follower_push': false, 'new_follower_email': true, @@ -27,6 +29,7 @@ const tFullJson = { 'newsletter_email': false, }; +/// Domain entity equivalent of [tFullJson] — used to verify [fromEntity] and [toDomain]. const tEntity = NotificationPreferencesEntity( newFollowerPush: false, newFollowerEmail: true, @@ -52,6 +55,12 @@ const tEntity = NotificationPreferencesEntity( newsletterEmail: false, ); +/// Tests for [NotificationPreferencesModel]. +/// +/// Covers [fromJson] mapping all 22 fields from snake_case keys, the +/// [MessagesFrom] enum conversion in both directions, [toJson] key names, +/// [fromEntity] copying from a domain entity, [toDomain] producing an equal +/// entity, and the all-true default behaviour when JSON fields are absent. void main() { group('NotificationPreferencesModel', () { group('fromJson', () { diff --git a/test/features/settings/data/models/privacy_settings_model_test.dart b/test/features/settings/data/models/privacy_settings_model_test.dart index c52720cd..c771cfba 100644 --- a/test/features/settings/data/models/privacy_settings_model_test.dart +++ b/test/features/settings/data/models/privacy_settings_model_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rythmify/features/settings/data/models/privacy_settings_model.dart'; import 'package:rythmify/features/settings/domain/entities/privacy_settings_entity.dart'; +/// Full JSON payload with all fields set to non-default values for thorough [fromJson] coverage. const tFullJson = { 'is_private': true, 'receive_messages_from_anyone': false, @@ -26,6 +27,11 @@ const tEntity = PrivacySettingsEntity( showTopFansOnTracks: false, ); +/// Tests for [PrivacySettingsModel]. +/// +/// Covers [fromJson] field mapping and defaults, [toPatch] diff computation +/// (only changed fields included, empty patch for identical models), [fromEntity] +/// copying, and [toDomain] producing an equal [PrivacySettingsEntity]. void main() { group('PrivacySettingsModel', () { group('fromJson', () { diff --git a/test/features/settings/data/repositories/settings_repo_impl_test.dart b/test/features/settings/data/repositories/settings_repo_impl_test.dart index f06d296b..c2fc222f 100644 --- a/test/features/settings/data/repositories/settings_repo_impl_test.dart +++ b/test/features/settings/data/repositories/settings_repo_impl_test.dart @@ -7,6 +7,7 @@ import 'package:rythmify/features/settings/data/repositories/settings_repo_impl. import 'package:rythmify/features/settings/domain/entities/notification_preferences_entity.dart'; import 'package:rythmify/features/settings/domain/entities/privacy_settings_entity.dart'; +/// Mock [SettingsRemoteDatasources] for stubbing all datasource calls in repo tests. class MockSettingsRemoteDatasources extends Mock implements SettingsRemoteDatasources {} @@ -68,6 +69,14 @@ const tNotifEntity = NotificationPreferencesEntity( newsletterEmail: false, ); +/// Tests for [SettingsRepoImpl]. +/// +/// Verifies that [getPrivacySettings] and [getNotificationPreferences] delegate +/// to the datasource and map results to domain entities. Verifies that +/// [updatePrivacySettings] skips the datasource call when the patch diff is +/// empty (unchanged settings), and that [updateNotificationPreferences] and +/// [deleteMyAccount] forward to the datasource with correct arguments. +/// Exception propagation is verified for all methods. void main() { late MockSettingsRemoteDatasources mockDatasource; late SettingsRepoImpl repo; diff --git a/test/features/settings/domain/entities/notification_preferences_entity_test.dart b/test/features/settings/domain/entities/notification_preferences_entity_test.dart index 6c9a989b..42cd1b1a 100644 --- a/test/features/settings/domain/entities/notification_preferences_entity_test.dart +++ b/test/features/settings/domain/entities/notification_preferences_entity_test.dart @@ -1,6 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rythmify/features/settings/domain/entities/notification_preferences_entity.dart'; +/// Fixture entity with alternating push=true/email=false values and +/// [MessagesFrom.everyone], used as the base for all [copyWith] tests. const tEntity = NotificationPreferencesEntity( newFollowerPush: true, newFollowerEmail: false, @@ -26,6 +28,11 @@ const tEntity = NotificationPreferencesEntity( newsletterEmail: true, ); +/// Tests for [NotificationPreferencesEntity]. +/// +/// Verifies [Equatable] value equality across all 22 fields, individual +/// [copyWith] overrides for every field, multi-field overrides, and the +/// [MessagesFrom] enum membership and cardinality. void main() { group('NotificationPreferencesEntity', () { test('supports value equality via Equatable', () { diff --git a/test/features/settings/domain/entities/privacy_settings_entity_test.dart b/test/features/settings/domain/entities/privacy_settings_entity_test.dart index a99202ce..6188d134 100644 --- a/test/features/settings/domain/entities/privacy_settings_entity_test.dart +++ b/test/features/settings/domain/entities/privacy_settings_entity_test.dart @@ -1,6 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rythmify/features/settings/domain/entities/privacy_settings_entity.dart'; +/// Fixture entity with all fields set to typical defaults, used as the +/// base for all [copyWith] and equality tests. const tEntity = PrivacySettingsEntity( isPrivate: false, receiveMessageFromAnyone: true, @@ -9,6 +11,11 @@ const tEntity = PrivacySettingsEntity( showTopFansOnTracks: true, ); +/// Tests for [PrivacySettingsEntity]. +/// +/// Verifies [Equatable] value equality across all 5 fields, individual +/// [copyWith] overrides per field, multi-field overrides, and that unchanged +/// fields are preserved through every copy operation. void main() { group('PrivacySettingsEntity', () { test('supports value equality via Equatable', () { diff --git a/test/features/settings/presentation/providers/notification_prefs_notifier_test.dart b/test/features/settings/presentation/providers/notification_prefs_notifier_test.dart index 48de802e..6b7da92e 100644 --- a/test/features/settings/presentation/providers/notification_prefs_notifier_test.dart +++ b/test/features/settings/presentation/providers/notification_prefs_notifier_test.dart @@ -6,8 +6,11 @@ import 'package:rythmify/features/settings/domain/repositories/settings_repo_int import 'package:rythmify/features/settings/presentation/providers/notification_prefs_notifier.dart'; import 'package:rythmify/features/settings/presentation/providers/settings_providers.dart'; +/// Mock [SettingsRepoInterface] for stubbing notification-preference reads and writes. class MockSettingsRepoInterface extends Mock implements SettingsRepoInterface {} +/// Fixture with every field enabled and [MessagesFrom.everyone], used as the +/// pre-save state in all [NotificationPrefsNotifier.save] tests. const tNotifEntity = NotificationPreferencesEntity( newFollowerPush: true, newFollowerEmail: true, @@ -33,6 +36,12 @@ const tNotifEntity = NotificationPreferencesEntity( newsletterEmail: true, ); +/// Tests for [NotificationPrefsNotifier] via [notificationPrefsProvider]. +/// +/// Verifies that [build] fetches from the repository once and exposes the entity, +/// that [save] optimistically applies the updated entity and then commits the +/// server response (or emits [AsyncError] and reverts on failure), and that the +/// correct entity is forwarded to [SettingsRepoInterface.updateNotificationPreferences]. void main() { late MockSettingsRepoInterface mockRepo; late ProviderContainer container; diff --git a/test/features/settings/presentation/providers/privacy_settings_notifier_test.dart b/test/features/settings/presentation/providers/privacy_settings_notifier_test.dart index 49dd531b..6b24b2a8 100644 --- a/test/features/settings/presentation/providers/privacy_settings_notifier_test.dart +++ b/test/features/settings/presentation/providers/privacy_settings_notifier_test.dart @@ -6,8 +6,10 @@ import 'package:rythmify/features/settings/domain/repositories/settings_repo_int import 'package:rythmify/features/settings/presentation/providers/privacy_settings_notifier.dart'; import 'package:rythmify/features/settings/presentation/providers/settings_providers.dart'; +/// Mock [SettingsRepoInterface] for stubbing privacy-settings reads and writes. class MockSettingsRepoInterface extends Mock implements SettingsRepoInterface {} +/// Fixture representing default privacy settings (public account, open messaging). const tPrivacyEntity = PrivacySettingsEntity( isPrivate: false, receiveMessageFromAnyone: true, @@ -16,6 +18,14 @@ const tPrivacyEntity = PrivacySettingsEntity( showTopFansOnTracks: true, ); +/// Tests for [PrivacySettingsNotifier] via [privacySettingsProvider]. +/// +/// Verifies that [build] loads from the repository once, that [save] applies +/// the optimistic state immediately and intentionally ignores the server response +/// (to avoid defaulted fields overwriting live toggles), and that errors are +/// silently swallowed while keeping the optimistic update in place. +/// Also verifies that both the previous and updated entities are passed to +/// [SettingsRepoInterface.updatePrivacySettings] for minimal PATCH diffing. void main() { late MockSettingsRepoInterface mockRepo; late ProviderContainer container; From b4a68e892a2c3dcbebe8442495fe15a2d3c6a0f1 Mon Sep 17 00:00:00 2001 From: RanaElgharabawy Date: Sat, 2 May 2026 00:49:22 +0300 Subject: [PATCH 7/7] lib/ test/ --- .../presentation/pages/chat_screen.dart | 29 +- .../push_notification_service.dart | 4 +- .../pages/inbox_settings_screen.dart | 16 +- .../datasource_implement_test.dart | 643 ++++++++++-------- .../data/models/conversation_model_test.dart | 184 +++-- .../potential_conversation_model_test.dart | 10 +- .../sent_message_request_model_test.dart | 32 +- .../repository_implement_test.dart | 291 ++++---- .../usecases/messaging_usecases_test.dart | 147 ++-- .../block_unblock_notifier_test.dart | 15 +- .../providers/mark_as_read_notifier_test.dart | 90 +-- .../providers/messages_notifier_test.dart | 194 +++--- .../messaging_providers_wiring_test.dart | 53 +- .../providers/send_message_notifier_test.dart | 166 +++-- .../settings_datasource_impl_test.dart | 129 ++-- .../notification_preferences_model_test.dart | 48 +- .../models/privacy_settings_model_test.dart | 38 +- .../repositories/settings_repo_impl_test.dart | 234 ++++--- .../notification_preferences_entity_test.dart | 17 +- .../privacy_settings_entity_test.dart | 20 +- .../notification_prefs_notifier_test.dart | 120 ++-- .../privacy_settings_notifier_test.dart | 221 +++--- 22 files changed, 1567 insertions(+), 1134 deletions(-) diff --git a/lib/features/messaging/presentation/pages/chat_screen.dart b/lib/features/messaging/presentation/pages/chat_screen.dart index a4b2b973..0d9e88e0 100644 --- a/lib/features/messaging/presentation/pages/chat_screen.dart +++ b/lib/features/messaging/presentation/pages/chat_screen.dart @@ -173,7 +173,9 @@ class _ChatScreenState extends ConsumerState { // reversed list: maxScrollExtent = visual top (oldest messages) if (pos.pixels >= pos.maxScrollExtent - 200) { ref - .read(messagesNotifierProvider(_effectiveConv!.conversationId).notifier) + .read( + messagesNotifierProvider(_effectiveConv!.conversationId).notifier, + ) .loadMore(); } } @@ -247,7 +249,8 @@ class _ChatScreenState extends ConsumerState { final bool isKeyboardOpen = MediaQuery.of(context).viewInsets.bottom > 0; - final participantId = _effectiveConv?.participantId ?? widget.newParticipantId; + final participantId = + _effectiveConv?.participantId ?? widget.newParticipantId; final isBlockedAsync = participantId != null && participantId.isNotEmpty ? ref.watch(isBlockedProvider(participantId)) @@ -271,7 +274,9 @@ class _ChatScreenState extends ConsumerState { if (id != null) context.push('/home/profile/$id'); }, child: Text( - _effectiveConv?.participantName ?? widget.newParticipantName ?? 'Chat', + _effectiveConv?.participantName ?? + widget.newParticipantName ?? + 'Chat', key: const Key('chat_participant_name_text'), ), ), @@ -300,7 +305,8 @@ class _ChatScreenState extends ConsumerState { ), ], ), - body: (widget.convId != null && _resolvedConv == null) || !_newConvCheckDone + body: + (widget.convId != null && _resolvedConv == null) || !_newConvCheckDone ? const Center(child: CircularProgressIndicator()) : _effectiveConv == null ? _blanckChatPage(isBlocked: isBlocked, isBlockedBy: isBlockedBy) @@ -638,7 +644,10 @@ class _ChatScreenState extends ConsumerState { child: SizedBox( width: 24, height: 24, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), ), ); } @@ -1084,12 +1093,18 @@ class _ChatScreenState extends ConsumerState { // If an existing conversation was found during the pre-check, send there. if (_resolvedConv != null) { - await _processAndSendMessages(_resolvedConv!.conversationId, controller.text); + await _processAndSendMessages( + _resolvedConv!.conversationId, + controller.text, + ); if (!mounted) return; ref.invalidate(conversationProvider); controller.clear(); setState(() => _selectedEmbeds.clear()); - context.go('/home/inbox/chat/${_resolvedConv!.conversationId}', extra: _resolvedConv); + context.go( + '/home/inbox/chat/${_resolvedConv!.conversationId}', + extra: _resolvedConv, + ); return; } diff --git a/lib/features/notifications/data/datasources/push_notification_service.dart b/lib/features/notifications/data/datasources/push_notification_service.dart index f5f54962..716ad710 100644 --- a/lib/features/notifications/data/datasources/push_notification_service.dart +++ b/lib/features/notifications/data/datasources/push_notification_service.dart @@ -163,7 +163,9 @@ class PushNotificationService { void _navigateFromData(Map data) { _navigateByType( type: data['type'] as String?, - resourceId: (data['resource_id'] ?? data['referenceId'] ?? data['conversationId']) as String?, + resourceId: + (data['resource_id'] ?? data['referenceId'] ?? data['conversationId']) + as String?, resourceType: data['resource_type'] as String?, ); } diff --git a/lib/features/settings/presentation/pages/inbox_settings_screen.dart b/lib/features/settings/presentation/pages/inbox_settings_screen.dart index 29c19097..9d2af8ab 100644 --- a/lib/features/settings/presentation/pages/inbox_settings_screen.dart +++ b/lib/features/settings/presentation/pages/inbox_settings_screen.dart @@ -36,13 +36,15 @@ class InboxSettingsScreen extends ConsumerWidget { 'If turned off, only people you follow can send you messages', initSwitchValue: prefs.messagesFrom == MessagesFrom.everyone, onSwitchChanged: (value) { - ref.read(notificationPrefsProvider.notifier).save( - prefs.copyWith( - messagesFrom: value - ? MessagesFrom.everyone - : MessagesFrom.followersOnly, - ), - ); + ref + .read(notificationPrefsProvider.notifier) + .save( + prefs.copyWith( + messagesFrom: value + ? MessagesFrom.everyone + : MessagesFrom.followersOnly, + ), + ); }, onTap: () {}, ), diff --git a/test/features/messaging/data/datasources/datasource_implement_test.dart b/test/features/messaging/data/datasources/datasource_implement_test.dart index c5c000ea..478a53aa 100644 --- a/test/features/messaging/data/datasources/datasource_implement_test.dart +++ b/test/features/messaging/data/datasources/datasource_implement_test.dart @@ -10,20 +10,20 @@ class MockDio extends Mock implements Dio {} /// Creates a successful [Response] wrapping [data] with HTTP 200. Response _resp(dynamic data) => Response( - data: data, - statusCode: 200, - requestOptions: RequestOptions(path: ''), - ); + data: data, + statusCode: 200, + requestOptions: RequestOptions(path: ''), +); /// Creates a [DioException] with the given [statusCode] for error-path testing. DioException _dioEx(int statusCode) => DioException( - requestOptions: RequestOptions(path: ''), - response: Response( - statusCode: statusCode, - requestOptions: RequestOptions(path: ''), - ), - type: DioExceptionType.badResponse, - ); + requestOptions: RequestOptions(path: ''), + response: Response( + statusCode: statusCode, + requestOptions: RequestOptions(path: ''), + ), + type: DioExceptionType.badResponse, +); /// Tests for [DatasourceImplement] — the Dio-backed HTTP datasource. /// @@ -62,9 +62,13 @@ void main() { group('getConversations', () { test('returns list of conversations on success', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': {'items': [tConvJson]}, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': { + 'items': [tConvJson], + }, + }), + ); final result = await ds.getConversations(); @@ -74,9 +78,11 @@ void main() { }); test('returns empty list when items is empty', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': {'items': []}, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': {'items': []}, + }), + ); expect(await ds.getConversations(), isEmpty); }); @@ -92,12 +98,14 @@ void main() { group('getMessages', () { test('parses messages when data field is a Map', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': { - 'messages': [tMsgJson], - 'pagination': {'total': 1}, - }, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': { + 'messages': [tMsgJson], + 'pagination': {'total': 1}, + }, + }), + ); final (msgs, total) = await ds.getMessages(conversationId: 'c1'); @@ -107,10 +115,12 @@ void main() { }); test('parses messages when data field is a List', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': [tMsgJson], - 'pagination': {'total': 1}, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': [tMsgJson], + 'pagination': {'total': 1}, + }), + ); final (msgs, total) = await ds.getMessages(conversationId: 'c1'); @@ -119,8 +129,9 @@ void main() { }); test('returns empty list and zero total when data field is null', () async { - when(() => mockDio.get(any())) - .thenAnswer((_) async => _resp({'data': null})); + when( + () => mockDio.get(any()), + ).thenAnswer((_) async => _resp({'data': null})); final (msgs, total) = await ds.getMessages(conversationId: 'c1'); @@ -129,12 +140,14 @@ void main() { }); test('uses pagination.total from map when present', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': { - 'messages': [tMsgJson], - 'pagination': {'total': 99}, - }, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': { + 'messages': [tMsgJson], + 'pagination': {'total': 99}, + }, + }), + ); final (_, total) = await ds.getMessages(conversationId: 'c1'); @@ -142,9 +155,13 @@ void main() { }); test('falls back to message count when pagination absent', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': {'messages': [tMsgJson]}, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': { + 'messages': [tMsgJson], + }, + }), + ); final (_, total) = await ds.getMessages(conversationId: 'c1'); @@ -152,10 +169,12 @@ void main() { }); test('uses top-level pagination when data is a List', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': [tMsgJson], - 'pagination': {'total': 10}, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': [tMsgJson], + 'pagination': {'total': 10}, + }), + ); final (_, total) = await ds.getMessages(conversationId: 'c1'); @@ -173,8 +192,9 @@ void main() { group('sendMessage', () { test('returns parsed MessageModel on success', () async { - when(() => mockDio.post(any(), data: any(named: 'data'))) - .thenAnswer((_) async => _resp({'data': tMsgJson})); + when( + () => mockDio.post(any(), data: any(named: 'data')), + ).thenAnswer((_) async => _resp({'data': tMsgJson})); final req = SentMessageRequestModel(body: 'Hi'); final result = await ds.sendMessage( @@ -187,56 +207,72 @@ void main() { }); test('calls the correct endpoint', () async { - when(() => mockDio.post(any(), data: any(named: 'data'))) - .thenAnswer((_) async => _resp({'data': tMsgJson})); + when( + () => mockDio.post(any(), data: any(named: 'data')), + ).thenAnswer((_) async => _resp({'data': tMsgJson})); await ds.sendMessage( conversationId: 'c1', requestContent: SentMessageRequestModel(body: 'Hi'), ); - verify(() => mockDio.post( - ApiEndPoints.sendMessage('c1'), - data: any(named: 'data'), - )).called(1); + verify( + () => mockDio.post( + ApiEndPoints.sendMessage('c1'), + data: any(named: 'data'), + ), + ).called(1); }); }); // ─── newConversation ───────────────────────────────────────────────────────── group('newConversation', () { - test('returns conversation directly when response includes conversation key', - () async { - when(() => mockDio.post(any(), data: any(named: 'data'))) - .thenAnswer((_) async => _resp({ - 'data': {'conversation': tConvJson}, - })); - - final result = await ds.newConversation(participantId: 'u2'); - - expect(result.conversationId, 'c1'); - expect(result.participantId, 'u2'); - }); + test( + 'returns conversation directly when response includes conversation key', + () async { + when(() => mockDio.post(any(), data: any(named: 'data'))).thenAnswer( + (_) async => _resp({ + 'data': {'conversation': tConvJson}, + }), + ); + + final result = await ds.newConversation(participantId: 'u2'); + + expect(result.conversationId, 'c1'); + expect(result.participantId, 'u2'); + }, + ); - test('falls back to getConversations when response has no conversation key', - () async { - when(() => mockDio.post(any(), data: any(named: 'data'))) - .thenAnswer((_) async => _resp({'data': {}})); - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': {'items': [tConvJson]}, - })); + test( + 'falls back to getConversations when response has no conversation key', + () async { + when( + () => mockDio.post(any(), data: any(named: 'data')), + ).thenAnswer((_) async => _resp({'data': {}})); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': { + 'items': [tConvJson], + }, + }), + ); - final result = await ds.newConversation(participantId: 'u2'); + final result = await ds.newConversation(participantId: 'u2'); - expect(result.participantId, 'u2'); - }); + expect(result.participantId, 'u2'); + }, + ); test('throws when fallback finds no matching participant', () async { - when(() => mockDio.post(any(), data: any(named: 'data'))) - .thenAnswer((_) async => _resp({'data': {}})); - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': {'items': []}, - })); + when( + () => mockDio.post(any(), data: any(named: 'data')), + ).thenAnswer((_) async => _resp({'data': {}})); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': {'items': []}, + }), + ); expect( () => ds.newConversation(participantId: 'ghost-user'), @@ -245,50 +281,54 @@ void main() { }); test('includes body in request when provided', () async { - when(() => mockDio.post(any(), data: any(named: 'data'))) - .thenAnswer((_) async => _resp({ - 'data': {'conversation': tConvJson}, - })); + when(() => mockDio.post(any(), data: any(named: 'data'))).thenAnswer( + (_) async => _resp({ + 'data': {'conversation': tConvJson}, + }), + ); await ds.newConversation(participantId: 'u2', body: 'Hello!'); - final captured = verify(() => mockDio.post( - any(), - data: captureAny(named: 'data'), - )).captured; + final captured = verify( + () => mockDio.post(any(), data: captureAny(named: 'data')), + ).captured; expect((captured.single as Map)['body'], 'Hello!'); }); test('includes track resource when trackId provided', () async { - when(() => mockDio.post(any(), data: any(named: 'data'))) - .thenAnswer((_) async => _resp({ - 'data': {'conversation': tConvJson}, - })); + when(() => mockDio.post(any(), data: any(named: 'data'))).thenAnswer( + (_) async => _resp({ + 'data': {'conversation': tConvJson}, + }), + ); await ds.newConversation(participantId: 'u2', trackId: 't1'); - final captured = verify(() => mockDio.post( - any(), - data: captureAny(named: 'data'), - )).captured; - expect((captured.single as Map)['resource'], - {'type': 'track', 'id': 't1'}); + final captured = verify( + () => mockDio.post(any(), data: captureAny(named: 'data')), + ).captured; + expect((captured.single as Map)['resource'], { + 'type': 'track', + 'id': 't1', + }); }); test('includes playlist resource when playlistId provided', () async { - when(() => mockDio.post(any(), data: any(named: 'data'))) - .thenAnswer((_) async => _resp({ - 'data': {'conversation': tConvJson}, - })); + when(() => mockDio.post(any(), data: any(named: 'data'))).thenAnswer( + (_) async => _resp({ + 'data': {'conversation': tConvJson}, + }), + ); await ds.newConversation(participantId: 'u2', playlistId: 'pl1'); - final captured = verify(() => mockDio.post( - any(), - data: captureAny(named: 'data'), - )).captured; - expect((captured.single as Map)['resource'], - {'type': 'playlist', 'id': 'pl1'}); + final captured = verify( + () => mockDio.post(any(), data: captureAny(named: 'data')), + ).captured; + expect((captured.single as Map)['resource'], { + 'type': 'playlist', + 'id': 'pl1', + }); }); }); @@ -296,18 +336,21 @@ void main() { group('ensureConversation', () { test('returns conversation on success', () async { - when(() => mockDio.post(any(), data: any(named: 'data'))) - .thenAnswer((_) async => _resp({ - 'data': {'conversation': tConvJson}, - })); + when(() => mockDio.post(any(), data: any(named: 'data'))).thenAnswer( + (_) async => _resp({ + 'data': {'conversation': tConvJson}, + }), + ); final result = await ds.ensureConversation(participantId: 'u2'); expect(result.conversationId, 'c1'); - verify(() => mockDio.post( - ApiEndPoints.ensureConversation, - data: any(named: 'data'), - )).called(1); + verify( + () => mockDio.post( + ApiEndPoints.ensureConversation, + data: any(named: 'data'), + ), + ).called(1); }); }); @@ -315,17 +358,21 @@ void main() { group('getUnreadCount', () { test('returns unread count on success', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': {'unread_count': 5}, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': {'unread_count': 5}, + }), + ); expect(await ds.getUnreadCount(), 5); }); test('returns 0 when count is zero', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': {'unread_count': 0}, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': {'unread_count': 0}, + }), + ); expect(await ds.getUnreadCount(), 0); }); @@ -357,8 +404,9 @@ void main() { group('markMessagesAsRead', () { test('completes on success', () async { - when(() => mockDio.patch(any(), data: any(named: 'data'))) - .thenAnswer((_) async => _resp(null)); + when( + () => mockDio.patch(any(), data: any(named: 'data')), + ).thenAnswer((_) async => _resp(null)); await expectLater( ds.markMessagesAsRead(conversationId: 'c1', messageId: 'm1'), @@ -367,8 +415,9 @@ void main() { }); test('swallows 409 DioException silently', () async { - when(() => mockDio.patch(any(), data: any(named: 'data'))) - .thenThrow(_dioEx(409)); + when( + () => mockDio.patch(any(), data: any(named: 'data')), + ).thenThrow(_dioEx(409)); await expectLater( ds.markMessagesAsRead(conversationId: 'c1', messageId: 'm1'), @@ -377,8 +426,9 @@ void main() { }); test('rethrows non-409 DioException', () async { - when(() => mockDio.patch(any(), data: any(named: 'data'))) - .thenThrow(_dioEx(500)); + when( + () => mockDio.patch(any(), data: any(named: 'data')), + ).thenThrow(_dioEx(500)); expect( () => ds.markMessagesAsRead(conversationId: 'c1', messageId: 'm1'), @@ -387,15 +437,18 @@ void main() { }); test('sends correct endpoint and payload', () async { - when(() => mockDio.patch(any(), data: any(named: 'data'))) - .thenAnswer((_) async => _resp(null)); + when( + () => mockDio.patch(any(), data: any(named: 'data')), + ).thenAnswer((_) async => _resp(null)); await ds.markMessagesAsRead(conversationId: 'c1', messageId: 'm1'); - verify(() => mockDio.patch( - ApiEndPoints.markMessagesAsRead('c1', 'm1'), - data: {'is_read': true}, - )).called(1); + verify( + () => mockDio.patch( + ApiEndPoints.markMessagesAsRead('c1', 'm1'), + data: {'is_read': true}, + ), + ).called(1); }); }); @@ -403,9 +456,13 @@ void main() { group('getFollowings', () { test('returns list of potential conversations', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': {'items': [tUserJson]}, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': { + 'items': [tUserJson], + }, + }), + ); final result = await ds.getFollowings('me'); @@ -415,9 +472,11 @@ void main() { }); test('returns empty list when items is empty', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': {'items': []}, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': {'items': []}, + }), + ); expect(await ds.getFollowings('me'), isEmpty); }); @@ -433,9 +492,13 @@ void main() { group('getSearchedUsers', () { test('returns matching users', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': {'users': [tUserJson]}, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': { + 'users': [tUserJson], + }, + }), + ); final result = await ds.getSearchedUsers('ali'); @@ -444,9 +507,11 @@ void main() { }); test('returns empty list when no users match', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': {'users': []}, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': {'users': []}, + }), + ); expect(await ds.getSearchedUsers('zzz'), isEmpty); }); @@ -454,10 +519,7 @@ void main() { test('propagates DioException', () async { when(() => mockDio.get(any())).thenThrow(_dioEx(500)); - expect( - () => ds.getSearchedUsers('ali'), - throwsA(isA()), - ); + expect(() => ds.getSearchedUsers('ali'), throwsA(isA())); }); }); @@ -465,17 +527,21 @@ void main() { group('isBlocked', () { test('returns true when is_blocking is true', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': {'is_blocking': true, 'is_blocked_by': false}, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': {'is_blocking': true, 'is_blocked_by': false}, + }), + ); expect(await ds.isBlocked('u2'), true); }); test('returns false when is_blocking is false', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': {'is_blocking': false, 'is_blocked_by': false}, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': {'is_blocking': false, 'is_blocked_by': false}, + }), + ); expect(await ds.isBlocked('u2'), false); }); @@ -491,17 +557,21 @@ void main() { group('isBlockedBy', () { test('returns true when is_blocked_by is true', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': {'is_blocking': false, 'is_blocked_by': true}, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': {'is_blocking': false, 'is_blocked_by': true}, + }), + ); expect(await ds.isBlockedBy('u2'), true); }); test('returns false when is_blocked_by is false', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': {'is_blocking': false, 'is_blocked_by': false}, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': {'is_blocking': false, 'is_blocked_by': false}, + }), + ); expect(await ds.isBlockedBy('u2'), false); }); @@ -518,17 +588,19 @@ void main() { group('getEmbeds', () { group('track', () { test('returns SharedEmbedModels from liked tracks', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': { - 'items': [ - { - 'track_id': 't1', - 'title': 'Track One', - 'cover_image': 'http://img', - } - ], - }, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': { + 'items': [ + { + 'track_id': 't1', + 'title': 'Track One', + 'cover_image': 'http://img', + }, + ], + }, + }), + ); final result = await ds.getEmbeds('me', 'track'); @@ -539,13 +611,15 @@ void main() { }); test('falls back to id field when track_id absent', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': { - 'items': [ - {'id': 't2', 'title': 'Track Two', 'cover_image': null} - ], - }, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': { + 'items': [ + {'id': 't2', 'title': 'Track Two', 'cover_image': null}, + ], + }, + }), + ); final result = await ds.getEmbeds('me', 'track'); @@ -554,30 +628,36 @@ void main() { }); group('playlist', () { - test('merges liked and created playlists and deduplicates by id', - () async { - final likedItems = [ - {'id': 'p1', 'name': 'Liked One', 'cover_image': null}, - {'id': 'p2', 'name': 'Shared', 'cover_image': null}, - ]; - final createdItems = [ - {'id': 'p2', 'name': 'Shared', 'cover_image': null}, - {'id': 'p3', 'name': 'Created One', 'cover_image': null}, - ]; - - when(() => mockDio.get(any())).thenAnswer((inv) async { - final url = inv.positionalArguments.first as String; - if (url.contains('liked-playlists')) { - return _resp({'data': {'items': likedItems}}); - } - return _resp({'data': {'items': createdItems}}); - }); - - final result = await ds.getEmbeds('me', 'playlist'); - - expect(result, hasLength(3)); - expect(result.map((e) => e.embedId), containsAll(['p1', 'p2', 'p3'])); - }); + test( + 'merges liked and created playlists and deduplicates by id', + () async { + final likedItems = [ + {'id': 'p1', 'name': 'Liked One', 'cover_image': null}, + {'id': 'p2', 'name': 'Shared', 'cover_image': null}, + ]; + final createdItems = [ + {'id': 'p2', 'name': 'Shared', 'cover_image': null}, + {'id': 'p3', 'name': 'Created One', 'cover_image': null}, + ]; + + when(() => mockDio.get(any())).thenAnswer((inv) async { + final url = inv.positionalArguments.first as String; + if (url.contains('liked-playlists')) { + return _resp({ + 'data': {'items': likedItems}, + }); + } + return _resp({ + 'data': {'items': createdItems}, + }); + }); + + final result = await ds.getEmbeds('me', 'playlist'); + + expect(result, hasLength(3)); + expect(result.map((e) => e.embedId), containsAll(['p1', 'p2', 'p3'])); + }, + ); test('skips items missing id or name', () async { final items = [ @@ -586,8 +666,11 @@ void main() { {'id': 'p3', 'cover_image': null}, ]; - when(() => mockDio.get(any())) - .thenAnswer((_) async => _resp({'data': {'items': items}})); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': {'items': items}, + }), + ); final result = await ds.getEmbeds('me', 'playlist'); @@ -597,8 +680,11 @@ void main() { }); test('returns empty list when both sources are empty', () async { - when(() => mockDio.get(any())) - .thenAnswer((_) async => _resp({'data': {'items': []}})); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': {'items': []}, + }), + ); expect(await ds.getEmbeds('me', 'playlist'), isEmpty); }); @@ -606,17 +692,15 @@ void main() { group('album', () { test('returns SharedEmbedModels from liked albums', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': { - 'items': [ - { - 'playlist_id': 'a1', - 'name': 'Album One', - 'cover_image': null, - } - ], - }, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': { + 'items': [ + {'playlist_id': 'a1', 'name': 'Album One', 'cover_image': null}, + ], + }, + }), + ); final result = await ds.getEmbeds('me', 'album'); @@ -636,14 +720,16 @@ void main() { group('getTrackDetails', () { test('parses track with artist_name field', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': { - 'id': 't1', - 'title': 'My Track', - 'artist_name': 'DJ One', - 'cover_image': 'http://img', - }, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': { + 'id': 't1', + 'title': 'My Track', + 'artist_name': 'DJ One', + 'cover_image': 'http://img', + }, + }), + ); final result = await ds.getTrackDetails('t1'); @@ -654,14 +740,16 @@ void main() { }); test('falls back to artist field when artist_name is absent', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': { - 'id': 't1', - 'title': 'My Track', - 'artist': 'DJ Two', - 'artwork_url': 'http://art', - }, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': { + 'id': 't1', + 'title': 'My Track', + 'artist': 'DJ Two', + 'artwork_url': 'http://art', + }, + }), + ); final result = await ds.getTrackDetails('t1'); @@ -669,30 +757,32 @@ void main() { expect(result.thumbnailUrl, 'http://art'); }); - test('falls back to artists field when both artist_name and artist absent', - () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + test( + 'falls back to artists field when both artist_name and artist absent', + () async { + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ 'data': { 'id': 't1', 'title': 'Collab', 'artists': 'Artist A & Artist B', 'cover_image': null, }, - })); + }), + ); - final result = await ds.getTrackDetails('t1'); + final result = await ds.getTrackDetails('t1'); - expect(result.artistName, 'Artist A & Artist B'); - }); + expect(result.artistName, 'Artist A & Artist B'); + }, + ); test('calls correct endpoint', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': { - 'id': 't1', - 'title': 'Track', - 'cover_image': null, - }, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': {'id': 't1', 'title': 'Track', 'cover_image': null}, + }), + ); await ds.getTrackDetails('t1'); @@ -704,14 +794,16 @@ void main() { group('getPlaylistDetails', () { test('returns playlist embed using playlist_id from response', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': { - 'playlist_id': 'pl1', - 'name': 'My Playlist', - 'owner_name': 'Alice', - 'cover_image': 'http://img', - }, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': { + 'playlist_id': 'pl1', + 'name': 'My Playlist', + 'owner_name': 'Alice', + 'cover_image': 'http://img', + }, + }), + ); final result = await ds.getPlaylistDetails('pl1', 'playlist'); @@ -721,36 +813,43 @@ void main() { expect(result.embedType, 'playlist'); }); - test('falls back to playlistId param when playlist_id absent in response', - () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ + test( + 'falls back to playlistId param when playlist_id absent in response', + () async { + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ 'data': { 'name': 'My Album', 'owner_name': 'Bob', 'cover_image': null, }, - })); + }), + ); - final result = await ds.getPlaylistDetails('a1', 'album'); + final result = await ds.getPlaylistDetails('a1', 'album'); - expect(result.embedId, 'a1'); - expect(result.embedType, 'album'); - }); + expect(result.embedId, 'a1'); + expect(result.embedType, 'album'); + }, + ); test('calls correct endpoint', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _resp({ - 'data': { - 'playlist_id': 'pl1', - 'name': 'P', - 'owner_name': 'X', - 'cover_image': null, - }, - })); + when(() => mockDio.get(any())).thenAnswer( + (_) async => _resp({ + 'data': { + 'playlist_id': 'pl1', + 'name': 'P', + 'owner_name': 'X', + 'cover_image': null, + }, + }), + ); await ds.getPlaylistDetails('pl1', 'playlist'); - verify(() => mockDio.get(ApiEndPoints.getPlaylistDetails('pl1'))) - .called(1); + verify( + () => mockDio.get(ApiEndPoints.getPlaylistDetails('pl1')), + ).called(1); }); }); } diff --git a/test/features/messaging/data/models/conversation_model_test.dart b/test/features/messaging/data/models/conversation_model_test.dart index 45d7af71..640a7c08 100644 --- a/test/features/messaging/data/models/conversation_model_test.dart +++ b/test/features/messaging/data/models/conversation_model_test.dart @@ -13,8 +13,7 @@ Map _buildJson({ }) { return { 'id': id, - 'participant': - participant ?? {'id': 'user-2', 'display_name': 'Alice'}, + 'participant': participant ?? {'id': 'user-2', 'display_name': 'Alice'}, if (lastMessage != null) 'last_message': lastMessage, 'unread_count': unreadCount, }; @@ -55,93 +54,124 @@ void main() { }); test('reads avatar from profile_picture field', () { - final model = ConversationModel.fromJson(_buildJson( - participant: { - 'id': 'user-2', - 'display_name': 'Alice', - 'profile_picture': 'https://example.com/pp.png', - }, - lastMessage: tLastMessage, - )); + final model = ConversationModel.fromJson( + _buildJson( + participant: { + 'id': 'user-2', + 'display_name': 'Alice', + 'profile_picture': 'https://example.com/pp.png', + }, + lastMessage: tLastMessage, + ), + ); expect(model.participantAvatar, 'https://example.com/pp.png'); }); test('falls back to avatar_url when profile_picture is absent', () { - final model = ConversationModel.fromJson(_buildJson( - participant: { - 'id': 'user-2', - 'display_name': 'Alice', - 'avatar_url': 'https://example.com/avatar_url.png', - }, - lastMessage: tLastMessage, - )); + final model = ConversationModel.fromJson( + _buildJson( + participant: { + 'id': 'user-2', + 'display_name': 'Alice', + 'avatar_url': 'https://example.com/avatar_url.png', + }, + lastMessage: tLastMessage, + ), + ); expect(model.participantAvatar, 'https://example.com/avatar_url.png'); }); - test('falls back to avatar field when profile_picture and avatar_url absent', () { - final model = ConversationModel.fromJson(_buildJson( - participant: { - 'id': 'user-2', - 'display_name': 'Alice', - 'avatar': 'https://example.com/avatar.png', - }, - lastMessage: tLastMessage, - )); - expect(model.participantAvatar, 'https://example.com/avatar.png'); - }); + test( + 'falls back to avatar field when profile_picture and avatar_url absent', + () { + final model = ConversationModel.fromJson( + _buildJson( + participant: { + 'id': 'user-2', + 'display_name': 'Alice', + 'avatar': 'https://example.com/avatar.png', + }, + lastMessage: tLastMessage, + ), + ); + expect(model.participantAvatar, 'https://example.com/avatar.png'); + }, + ); test('participantAvatar is null when no avatar key present', () { - final model = ConversationModel.fromJson(_buildJson( - participant: {'id': 'user-2', 'display_name': 'Alice'}, - lastMessage: tLastMessage, - )); + final model = ConversationModel.fromJson( + _buildJson( + participant: {'id': 'user-2', 'display_name': 'Alice'}, + lastMessage: tLastMessage, + ), + ); expect(model.participantAvatar, isNull); }); test('uses body as lastMessagePreview when present', () { - final model = ConversationModel.fromJson(_buildJson( - lastMessage: {'body': 'Check this out', 'created_at': '2024-06-01T12:00:00.000Z'}, - )); + final model = ConversationModel.fromJson( + _buildJson( + lastMessage: { + 'body': 'Check this out', + 'created_at': '2024-06-01T12:00:00.000Z', + }, + ), + ); expect(model.lastMessagePreview, 'Check this out'); }); test('uses track URL when embed_type is track', () { - final model = ConversationModel.fromJson(_buildJson( - lastMessage: { - 'embed_id': 'track-999', - 'embed_type': 'track', - 'created_at': '2024-06-01T12:00:00.000Z', - }, - )); - expect(model.lastMessagePreview, 'https://rythmify.com/tracks/track-999'); + final model = ConversationModel.fromJson( + _buildJson( + lastMessage: { + 'embed_id': 'track-999', + 'embed_type': 'track', + 'created_at': '2024-06-01T12:00:00.000Z', + }, + ), + ); + expect( + model.lastMessagePreview, + 'https://rythmify.com/tracks/track-999', + ); }); test('uses playlist URL when embed_type is playlist', () { - final model = ConversationModel.fromJson(_buildJson( - lastMessage: { - 'embed_id': 'pl-111', - 'embed_type': 'playlist', - 'created_at': '2024-06-01T12:00:00.000Z', - }, - )); - expect(model.lastMessagePreview, 'https://rythmify.com/playlists/pl-111'); + final model = ConversationModel.fromJson( + _buildJson( + lastMessage: { + 'embed_id': 'pl-111', + 'embed_type': 'playlist', + 'created_at': '2024-06-01T12:00:00.000Z', + }, + ), + ); + expect( + model.lastMessagePreview, + 'https://rythmify.com/playlists/pl-111', + ); }); test('uses playlist URL when embed_type is album', () { - final model = ConversationModel.fromJson(_buildJson( - lastMessage: { - 'embed_id': 'alb-222', - 'embed_type': 'album', - 'created_at': '2024-06-01T12:00:00.000Z', - }, - )); - expect(model.lastMessagePreview, 'https://rythmify.com/playlists/alb-222'); + final model = ConversationModel.fromJson( + _buildJson( + lastMessage: { + 'embed_id': 'alb-222', + 'embed_type': 'album', + 'created_at': '2024-06-01T12:00:00.000Z', + }, + ), + ); + expect( + model.lastMessagePreview, + 'https://rythmify.com/playlists/alb-222', + ); }); test('defaults lastMessagePreview to empty when no body or embed', () { - final model = ConversationModel.fromJson(_buildJson( - lastMessage: {'created_at': '2024-06-01T12:00:00.000Z'}, - )); + final model = ConversationModel.fromJson( + _buildJson(lastMessage: {'created_at': '2024-06-01T12:00:00.000Z'}), + ); expect(model.lastMessagePreview, ''); }); @@ -155,18 +185,19 @@ void main() { }); test('defaults participantName to Unknown when display_name missing', () { - final model = ConversationModel.fromJson(_buildJson( - participant: {'id': 'user-2'}, - lastMessage: tLastMessage, - )); + final model = ConversationModel.fromJson( + _buildJson(participant: {'id': 'user-2'}, lastMessage: tLastMessage), + ); expect(model.participantName, 'Unknown'); }); test('defaults participantId to empty string when id missing', () { - final model = ConversationModel.fromJson(_buildJson( - participant: {'display_name': 'Alice'}, - lastMessage: tLastMessage, - )); + final model = ConversationModel.fromJson( + _buildJson( + participant: {'display_name': 'Alice'}, + lastMessage: tLastMessage, + ), + ); expect(model.participantId, ''); }); @@ -194,9 +225,9 @@ void main() { test('uses DateTime.now() when last_message created_at is null', () { final before = DateTime.now(); - final model = ConversationModel.fromJson(_buildJson( - lastMessage: {'body': 'Hi'}, - )); + final model = ConversationModel.fromJson( + _buildJson(lastMessage: {'body': 'Hi'}), + ); final after = DateTime.now(); expect( model.lastMessageDate.millisecondsSinceEpoch, @@ -226,7 +257,10 @@ void main() { final participant = json['participant'] as Map; expect(participant['id'], 'user-2'); expect(participant['display_name'], 'Alice'); - expect(participant['profile_picture'], 'https://example.com/avatar.png'); + expect( + participant['profile_picture'], + 'https://example.com/avatar.png', + ); final lastMsg = json['last_message'] as Map?; expect(lastMsg, isNotNull); expect(lastMsg!['body'], 'Hey there'); diff --git a/test/features/messaging/data/models/potential_conversation_model_test.dart b/test/features/messaging/data/models/potential_conversation_model_test.dart index 7c5e8b33..0c7a08ce 100644 --- a/test/features/messaging/data/models/potential_conversation_model_test.dart +++ b/test/features/messaging/data/models/potential_conversation_model_test.dart @@ -25,19 +25,13 @@ void main() { }); test('falls back to user_id when id is null', () { - final json = { - 'user_id': 'user-2', - 'display_name': 'Bob', - }; + final json = {'user_id': 'user-2', 'display_name': 'Bob'}; final model = PotentialConversationModel.fromJson(json); expect(model.participantId, 'user-2'); }); test('optional fields default to null when absent', () { - final json = { - 'id': 'user-3', - 'display_name': 'Charlie', - }; + final json = {'id': 'user-3', 'display_name': 'Charlie'}; final model = PotentialConversationModel.fromJson(json); expect(model.followersCount, isNull); expect(model.avatar, isNull); diff --git a/test/features/messaging/data/models/sent_message_request_model_test.dart b/test/features/messaging/data/models/sent_message_request_model_test.dart index 4f1c8176..f1f73bd5 100644 --- a/test/features/messaging/data/models/sent_message_request_model_test.dart +++ b/test/features/messaging/data/models/sent_message_request_model_test.dart @@ -26,14 +26,17 @@ void main() { expect(json['resource'], {'type': 'track', 'id': 'track-123'}); }); - test('returns resource with type playlist when embedType is playlist', () { - final model = SentMessageRequestModel( - embedId: 'pl-456', - embedType: 'playlist', - ); - final json = model.toJson(); - expect(json['resource'], {'type': 'playlist', 'id': 'pl-456'}); - }); + test( + 'returns resource with type playlist when embedType is playlist', + () { + final model = SentMessageRequestModel( + embedId: 'pl-456', + embedType: 'playlist', + ); + final json = model.toJson(); + expect(json['resource'], {'type': 'playlist', 'id': 'pl-456'}); + }, + ); test('returns resource with type playlist when embedType is album', () { final model = SentMessageRequestModel( @@ -60,11 +63,14 @@ void main() { expect(model.toJson(), isEmpty); }); - test('does not include resource when embedId is null even if embedType is set', () { - final model = SentMessageRequestModel(embedType: 'track'); - final json = model.toJson(); - expect(json.containsKey('resource'), false); - }); + test( + 'does not include resource when embedId is null even if embedType is set', + () { + final model = SentMessageRequestModel(embedType: 'track'); + final json = model.toJson(); + expect(json.containsKey('resource'), false); + }, + ); }); test('all fields are optional in constructor', () { diff --git a/test/features/messaging/data/repositories/repository_implement_test.dart b/test/features/messaging/data/repositories/repository_implement_test.dart index def0426b..8abdd8ba 100644 --- a/test/features/messaging/data/repositories/repository_implement_test.dart +++ b/test/features/messaging/data/repositories/repository_implement_test.dart @@ -62,8 +62,9 @@ void main() { group('RepositoryImplement', () { group('getConversations', () { test('delegates to datasource and returns result', () async { - when(() => mockDatasource.getConversations()) - .thenAnswer((_) async => [tConversationModel]); + when( + () => mockDatasource.getConversations(), + ).thenAnswer((_) async => [tConversationModel]); final result = await repo.getConversations(); @@ -72,8 +73,9 @@ void main() { }); test('returns empty list when datasource returns empty', () async { - when(() => mockDatasource.getConversations()) - .thenAnswer((_) async => []); + when( + () => mockDatasource.getConversations(), + ).thenAnswer((_) async => []); final result = await repo.getConversations(); expect(result, isEmpty); @@ -81,45 +83,54 @@ void main() { }); group('getMessages', () { - test('delegates with correct conversationId and default offset', () async { - when(() => mockDatasource.getMessages( + test( + 'delegates with correct conversationId and default offset', + () async { + when( + () => mockDatasource.getMessages( conversationId: any(named: 'conversationId'), offset: any(named: 'offset'), - )).thenAnswer((_) async => ([tMessageModel], 1)); + ), + ).thenAnswer((_) async => ([tMessageModel], 1)); - final (messages, total) = await repo.getMessages('conv-1'); + final (messages, total) = await repo.getMessages('conv-1'); - expect(messages, [tMessageModel]); - expect(total, 1); - verify(() => mockDatasource.getMessages( - conversationId: 'conv-1', - offset: 0, - )).called(1); - }); + expect(messages, [tMessageModel]); + expect(total, 1); + verify( + () => + mockDatasource.getMessages(conversationId: 'conv-1', offset: 0), + ).called(1); + }, + ); test('passes offset to datasource', () async { - when(() => mockDatasource.getMessages( - conversationId: any(named: 'conversationId'), - offset: any(named: 'offset'), - )).thenAnswer((_) async => ([tMessageModel], 50)); + when( + () => mockDatasource.getMessages( + conversationId: any(named: 'conversationId'), + offset: any(named: 'offset'), + ), + ).thenAnswer((_) async => ([tMessageModel], 50)); await repo.getMessages('conv-1', offset: 100); - verify(() => mockDatasource.getMessages( - conversationId: 'conv-1', - offset: 100, - )).called(1); + verify( + () => + mockDatasource.getMessages(conversationId: 'conv-1', offset: 100), + ).called(1); }); }); group('startConversation', () { test('calls datasource.newConversation with correct args', () async { - when(() => mockDatasource.newConversation( - participantId: any(named: 'participantId'), - body: any(named: 'body'), - trackId: any(named: 'trackId'), - playlistId: any(named: 'playlistId'), - )).thenAnswer((_) async => tConversationModel); + when( + () => mockDatasource.newConversation( + participantId: any(named: 'participantId'), + body: any(named: 'body'), + trackId: any(named: 'trackId'), + playlistId: any(named: 'playlistId'), + ), + ).thenAnswer((_) async => tConversationModel); final result = await repo.startConversation( 'user-2', @@ -129,57 +140,73 @@ void main() { ); expect(result, tConversationModel); - verify(() => mockDatasource.newConversation( - participantId: 'user-2', - body: 'Hi', - trackId: 'track-1', - playlistId: null, - )).called(1); + verify( + () => mockDatasource.newConversation( + participantId: 'user-2', + body: 'Hi', + trackId: 'track-1', + playlistId: null, + ), + ).called(1); }); test('passes null body and embed when not provided', () async { - when(() => mockDatasource.newConversation( - participantId: any(named: 'participantId'), - body: any(named: 'body'), - trackId: any(named: 'trackId'), - playlistId: any(named: 'playlistId'), - )).thenAnswer((_) async => tConversationModel); + when( + () => mockDatasource.newConversation( + participantId: any(named: 'participantId'), + body: any(named: 'body'), + trackId: any(named: 'trackId'), + playlistId: any(named: 'playlistId'), + ), + ).thenAnswer((_) async => tConversationModel); await repo.startConversation('user-2', null, null, null); - verify(() => mockDatasource.newConversation( - participantId: 'user-2', - body: null, - trackId: null, - playlistId: null, - )).called(1); + verify( + () => mockDatasource.newConversation( + participantId: 'user-2', + body: null, + trackId: null, + playlistId: null, + ), + ).called(1); }); }); group('sendMessage', () { - test('builds SentMessageRequestModel and delegates to datasource', () async { - when(() => mockDatasource.sendMessage( + test( + 'builds SentMessageRequestModel and delegates to datasource', + () async { + when( + () => mockDatasource.sendMessage( conversationId: any(named: 'conversationId'), requestContent: any(named: 'requestContent'), - )).thenAnswer((_) async => tMessageModel); + ), + ).thenAnswer((_) async => tMessageModel); - final result = await repo.sendMessage('conv-1', 'Hello', null, null); + final result = await repo.sendMessage('conv-1', 'Hello', null, null); - expect(result, tMessageModel); - verify(() => mockDatasource.sendMessage( + expect(result, tMessageModel); + verify( + () => mockDatasource.sendMessage( conversationId: 'conv-1', requestContent: any(named: 'requestContent'), - )).called(1); - }); + ), + ).called(1); + }, + ); test('passes embedId and embedType to SentMessageRequestModel', () async { SentMessageRequestModel? captured; - when(() => mockDatasource.sendMessage( - conversationId: any(named: 'conversationId'), - requestContent: any(named: 'requestContent'), - )).thenAnswer((invocation) async { - captured = invocation.namedArguments[const Symbol('requestContent')] - as SentMessageRequestModel; + when( + () => mockDatasource.sendMessage( + conversationId: any(named: 'conversationId'), + requestContent: any(named: 'requestContent'), + ), + ).thenAnswer((invocation) async { + captured = + invocation.namedArguments[const Symbol('requestContent')] + as SentMessageRequestModel; return tMessageModel; }); @@ -193,8 +220,9 @@ void main() { group('blockUser', () { test('delegates userId to datasource.blockUser', () async { - when(() => mockDatasource.blockUser(userId: any(named: 'userId'))) - .thenAnswer((_) async {}); + when( + () => mockDatasource.blockUser(userId: any(named: 'userId')), + ).thenAnswer((_) async {}); await repo.blockUser('user-2'); @@ -204,8 +232,9 @@ void main() { group('unBlockUser', () { test('delegates userId to datasource.unBlockUser', () async { - when(() => mockDatasource.unBlockUser(userId: any(named: 'userId'))) - .thenAnswer((_) async {}); + when( + () => mockDatasource.unBlockUser(userId: any(named: 'userId')), + ).thenAnswer((_) async {}); await repo.unBlockUser('user-2'); @@ -215,35 +244,45 @@ void main() { group('markMessageAsRead', () { test('passes messageId and conversationId in correct order', () async { - when(() => mockDatasource.markMessagesAsRead( - conversationId: any(named: 'conversationId'), - messageId: any(named: 'messageId'), - )).thenAnswer((_) async {}); + when( + () => mockDatasource.markMessagesAsRead( + conversationId: any(named: 'conversationId'), + messageId: any(named: 'messageId'), + ), + ).thenAnswer((_) async {}); await repo.markMessageAsRead('msg-1', 'conv-1'); - verify(() => mockDatasource.markMessagesAsRead( - conversationId: 'conv-1', - messageId: 'msg-1', - )).called(1); + verify( + () => mockDatasource.markMessagesAsRead( + conversationId: 'conv-1', + messageId: 'msg-1', + ), + ).called(1); }); }); group('getUnReadCount', () { - test('delegates to datasource.getUnreadCount and returns value', () async { - when(() => mockDatasource.getUnreadCount()).thenAnswer((_) async => 5); - - final result = await repo.getUnReadCount(); - - expect(result, 5); - verify(() => mockDatasource.getUnreadCount()).called(1); - }); + test( + 'delegates to datasource.getUnreadCount and returns value', + () async { + when( + () => mockDatasource.getUnreadCount(), + ).thenAnswer((_) async => 5); + + final result = await repo.getUnReadCount(); + + expect(result, 5); + verify(() => mockDatasource.getUnreadCount()).called(1); + }, + ); }); group('getFollowings', () { test('delegates myId to datasource and returns result', () async { - when(() => mockDatasource.getFollowings(any())) - .thenAnswer((_) async => [tPotentialModel]); + when( + () => mockDatasource.getFollowings(any()), + ).thenAnswer((_) async => [tPotentialModel]); final result = await repo.getFollowings('my-id'); @@ -254,8 +293,9 @@ void main() { group('getSearchedUsers', () { test('delegates query to datasource and returns result', () async { - when(() => mockDatasource.getSearchedUsers(any())) - .thenAnswer((_) async => [tPotentialModel]); + when( + () => mockDatasource.getSearchedUsers(any()), + ).thenAnswer((_) async => [tPotentialModel]); final result = await repo.getSearchedUsers('alice'); @@ -266,16 +306,18 @@ void main() { group('isBlocked', () { test('returns true when datasource returns true', () async { - when(() => mockDatasource.isBlocked(any())) - .thenAnswer((_) async => true); + when( + () => mockDatasource.isBlocked(any()), + ).thenAnswer((_) async => true); expect(await repo.isBlocked('user-2'), true); verify(() => mockDatasource.isBlocked('user-2')).called(1); }); test('returns false when datasource returns false', () async { - when(() => mockDatasource.isBlocked(any())) - .thenAnswer((_) async => false); + when( + () => mockDatasource.isBlocked(any()), + ).thenAnswer((_) async => false); expect(await repo.isBlocked('user-3'), false); }); @@ -283,8 +325,9 @@ void main() { group('isBlockedBy', () { test('returns bool from datasource', () async { - when(() => mockDatasource.isBlockedBy(any())) - .thenAnswer((_) async => true); + when( + () => mockDatasource.isBlockedBy(any()), + ).thenAnswer((_) async => true); expect(await repo.isBlockedBy('user-2'), true); verify(() => mockDatasource.isBlockedBy('user-2')).called(1); @@ -292,21 +335,26 @@ void main() { }); group('getLikedEmbeds', () { - test('delegates to datasource.getEmbeds with userId and embedType', () async { - when(() => mockDatasource.getEmbeds(any(), any())) - .thenAnswer((_) async => [tSharedEmbedModel]); - - final result = await repo.getLikedEmbeds('user-1', 'track'); - - expect(result, [tSharedEmbedModel]); - verify(() => mockDatasource.getEmbeds('user-1', 'track')).called(1); - }); + test( + 'delegates to datasource.getEmbeds with userId and embedType', + () async { + when( + () => mockDatasource.getEmbeds(any(), any()), + ).thenAnswer((_) async => [tSharedEmbedModel]); + + final result = await repo.getLikedEmbeds('user-1', 'track'); + + expect(result, [tSharedEmbedModel]); + verify(() => mockDatasource.getEmbeds('user-1', 'track')).called(1); + }, + ); }); group('getTrack', () { test('delegates to datasource.getTrackDetails', () async { - when(() => mockDatasource.getTrackDetails(any())) - .thenAnswer((_) async => tSharedEmbedModel); + when( + () => mockDatasource.getTrackDetails(any()), + ).thenAnswer((_) async => tSharedEmbedModel); final result = await repo.getTrack('track-1'); @@ -316,30 +364,37 @@ void main() { }); group('getPlaylist', () { - test('delegates to datasource.getPlaylistDetails with id and type', () async { - when(() => mockDatasource.getPlaylistDetails(any(), any())) - .thenAnswer((_) async => tSharedEmbedModel); - - final result = await repo.getPlaylist('pl-1', 'playlist'); - - expect(result, tSharedEmbedModel); - verify(() => mockDatasource.getPlaylistDetails('pl-1', 'playlist')) - .called(1); - }); + test( + 'delegates to datasource.getPlaylistDetails with id and type', + () async { + when( + () => mockDatasource.getPlaylistDetails(any(), any()), + ).thenAnswer((_) async => tSharedEmbedModel); + + final result = await repo.getPlaylist('pl-1', 'playlist'); + + expect(result, tSharedEmbedModel); + verify( + () => mockDatasource.getPlaylistDetails('pl-1', 'playlist'), + ).called(1); + }, + ); }); group('ensureConversation', () { test('delegates participantId to datasource', () async { - when(() => mockDatasource.ensureConversation( - participantId: any(named: 'participantId'), - )).thenAnswer((_) async => tConversationModel); + when( + () => mockDatasource.ensureConversation( + participantId: any(named: 'participantId'), + ), + ).thenAnswer((_) async => tConversationModel); final result = await repo.ensureConversation('user-2'); expect(result, tConversationModel); - verify(() => mockDatasource.ensureConversation( - participantId: 'user-2', - )).called(1); + verify( + () => mockDatasource.ensureConversation(participantId: 'user-2'), + ).called(1); }); }); }); diff --git a/test/features/messaging/domain/usecases/messaging_usecases_test.dart b/test/features/messaging/domain/usecases/messaging_usecases_test.dart index a9fc819d..7872ff8a 100644 --- a/test/features/messaging/domain/usecases/messaging_usecases_test.dart +++ b/test/features/messaging/domain/usecases/messaging_usecases_test.dart @@ -70,8 +70,9 @@ void main() { group('GetConversationsUsecase', () { test('calls repo.getConversations and returns list', () async { - when(() => mockRepo.getConversations()) - .thenAnswer((_) async => [tConversation]); + when( + () => mockRepo.getConversations(), + ).thenAnswer((_) async => [tConversation]); final result = await GetConversationsUsecase(repo: mockRepo)(); @@ -86,23 +87,24 @@ void main() { }); test('propagates exceptions from repo', () { - when(() => mockRepo.getConversations()) - .thenThrow(Exception('Network error')); + when( + () => mockRepo.getConversations(), + ).thenThrow(Exception('Network error')); - expect( - () => GetConversationsUsecase(repo: mockRepo)(), - throwsException, - ); + expect(() => GetConversationsUsecase(repo: mockRepo)(), throwsException); }); }); group('GetMessagesUsecase', () { test('calls repo.getMessages with conversationId and offset', () async { - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async => ([tMessage], 1)); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async => ([tMessage], 1)); - final (messages, total) = - await GetMessagesUsecase(repo: mockRepo)('conv-1', offset: 10); + final (messages, total) = await GetMessagesUsecase(repo: mockRepo)( + 'conv-1', + offset: 10, + ); expect(messages, [tMessage]); expect(total, 1); @@ -110,8 +112,9 @@ void main() { }); test('uses default offset=0 when not specified', () async { - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async => ([tMessage], 1)); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async => ([tMessage], 1)); await GetMessagesUsecase(repo: mockRepo)('conv-1'); @@ -121,8 +124,9 @@ void main() { group('SendMessageUsecase', () { test('calls repo.sendMessage with all args', () async { - when(() => mockRepo.sendMessage(any(), any(), any(), any())) - .thenAnswer((_) async => tMessage); + when( + () => mockRepo.sendMessage(any(), any(), any(), any()), + ).thenAnswer((_) async => tMessage); final result = await SendMessageUsecase(repo: mockRepo)( 'conv-1', @@ -132,13 +136,15 @@ void main() { ); expect(result, tMessage); - verify(() => mockRepo.sendMessage('conv-1', 'Hello', null, null)) - .called(1); + verify( + () => mockRepo.sendMessage('conv-1', 'Hello', null, null), + ).called(1); }); test('passes embedId and embedType through', () async { - when(() => mockRepo.sendMessage(any(), any(), any(), any())) - .thenAnswer((_) async => tMessage); + when( + () => mockRepo.sendMessage(any(), any(), any(), any()), + ).thenAnswer((_) async => tMessage); await SendMessageUsecase(repo: mockRepo)( 'conv-1', @@ -147,15 +153,17 @@ void main() { 'track', ); - verify(() => mockRepo.sendMessage('conv-1', null, 'track-1', 'track')) - .called(1); + verify( + () => mockRepo.sendMessage('conv-1', null, 'track-1', 'track'), + ).called(1); }); }); group('StartConversationUsecase', () { test('calls repo.startConversation with all args', () async { - when(() => mockRepo.startConversation(any(), any(), any(), any())) - .thenAnswer((_) async => tConversation); + when( + () => mockRepo.startConversation(any(), any(), any(), any()), + ).thenAnswer((_) async => tConversation); final result = await StartConversationUsecase(repo: mockRepo)( 'user-2', @@ -165,18 +173,21 @@ void main() { ); expect(result, tConversation); - verify(() => mockRepo.startConversation('user-2', 'Hello', 'track-1', null)) - .called(1); + verify( + () => mockRepo.startConversation('user-2', 'Hello', 'track-1', null), + ).called(1); }); test('passes null optional args', () async { - when(() => mockRepo.startConversation(any(), any(), any(), any())) - .thenAnswer((_) async => tConversation); + when( + () => mockRepo.startConversation(any(), any(), any(), any()), + ).thenAnswer((_) async => tConversation); await StartConversationUsecase(repo: mockRepo)('user-2'); - verify(() => mockRepo.startConversation('user-2', null, null, null)) - .called(1); + verify( + () => mockRepo.startConversation('user-2', null, null, null), + ).called(1); }); }); @@ -207,18 +218,23 @@ void main() { }); group('MarkMessagesAsReadUsecase', () { - test('calls repo.markMessageAsRead with messageId then conversationId', () async { - when(() => mockRepo.markMessageAsRead(any(), any())) - .thenAnswer((_) async {}); + test( + 'calls repo.markMessageAsRead with messageId then conversationId', + () async { + when( + () => mockRepo.markMessageAsRead(any(), any()), + ).thenAnswer((_) async {}); - await MarkMessagesAsReadUsecase(repo: mockRepo)('msg-1', 'conv-1'); + await MarkMessagesAsReadUsecase(repo: mockRepo)('msg-1', 'conv-1'); - verify(() => mockRepo.markMessageAsRead('msg-1', 'conv-1')).called(1); - }); + verify(() => mockRepo.markMessageAsRead('msg-1', 'conv-1')).called(1); + }, + ); test('propagates exceptions', () { - when(() => mockRepo.markMessageAsRead(any(), any())) - .thenThrow(Exception('Server error')); + when( + () => mockRepo.markMessageAsRead(any(), any()), + ).thenThrow(Exception('Server error')); expect( () => MarkMessagesAsReadUsecase(repo: mockRepo)('msg-1', 'conv-1'), @@ -246,8 +262,9 @@ void main() { group('GetFollowingsUsecase', () { test('calls repo.getFollowings with userId', () async { - when(() => mockRepo.getFollowings(any())) - .thenAnswer((_) async => [tPotential]); + when( + () => mockRepo.getFollowings(any()), + ).thenAnswer((_) async => [tPotential]); final result = await GetFollowingsUsecase(repo: mockRepo)('my-id'); @@ -264,8 +281,9 @@ void main() { group('GetSearchedUsersUsecase', () { test('calls repo.getSearchedUsers with query', () async { - when(() => mockRepo.getSearchedUsers(any())) - .thenAnswer((_) async => [tPotential]); + when( + () => mockRepo.getSearchedUsers(any()), + ).thenAnswer((_) async => [tPotential]); final result = await GetSearchedUsersUsecase(repo: mockRepo)('alice'); @@ -306,19 +324,23 @@ void main() { group('GetLikedEmbedsUseCase', () { test('calls repo.getLikedEmbeds with userId and embedType', () async { - when(() => mockRepo.getLikedEmbeds(any(), any())) - .thenAnswer((_) async => [tEmbed]); + when( + () => mockRepo.getLikedEmbeds(any(), any()), + ).thenAnswer((_) async => [tEmbed]); - final result = - await GetLikedEmbedsUseCase(repo: mockRepo)('user-1', 'track'); + final result = await GetLikedEmbedsUseCase(repo: mockRepo)( + 'user-1', + 'track', + ); expect(result, [tEmbed]); verify(() => mockRepo.getLikedEmbeds('user-1', 'track')).called(1); }); test('returns empty list when no liked embeds', () async { - when(() => mockRepo.getLikedEmbeds(any(), any())) - .thenAnswer((_) async => []); + when( + () => mockRepo.getLikedEmbeds(any(), any()), + ).thenAnswer((_) async => []); expect( await GetLikedEmbedsUseCase(repo: mockRepo)('user-1', 'playlist'), @@ -340,19 +362,23 @@ void main() { group('GetPlaylistDetailsUsecase', () { test('calls repo.getPlaylist with id and type', () async { - when(() => mockRepo.getPlaylist(any(), any())) - .thenAnswer((_) async => tEmbed); + when( + () => mockRepo.getPlaylist(any(), any()), + ).thenAnswer((_) async => tEmbed); - final result = - await GetPlaylistDetailsUsecase(repo: mockRepo)('pl-1', 'playlist'); + final result = await GetPlaylistDetailsUsecase(repo: mockRepo)( + 'pl-1', + 'playlist', + ); expect(result, tEmbed); verify(() => mockRepo.getPlaylist('pl-1', 'playlist')).called(1); }); test('passes album type through', () async { - when(() => mockRepo.getPlaylist(any(), any())) - .thenAnswer((_) async => tEmbed); + when( + () => mockRepo.getPlaylist(any(), any()), + ).thenAnswer((_) async => tEmbed); await GetPlaylistDetailsUsecase(repo: mockRepo)('alb-1', 'album'); @@ -362,19 +388,20 @@ void main() { group('EnsureConversationUsecase', () { test('calls repo.ensureConversation and returns conversation', () async { - when(() => mockRepo.ensureConversation(any())) - .thenAnswer((_) async => tConversation); + when( + () => mockRepo.ensureConversation(any()), + ).thenAnswer((_) async => tConversation); - final result = - await EnsureConversationUsecase(repo: mockRepo)('user-2'); + final result = await EnsureConversationUsecase(repo: mockRepo)('user-2'); expect(result, tConversation); verify(() => mockRepo.ensureConversation('user-2')).called(1); }); test('propagates exceptions', () { - when(() => mockRepo.ensureConversation(any())) - .thenThrow(Exception('Not found')); + when( + () => mockRepo.ensureConversation(any()), + ).thenThrow(Exception('Not found')); expect( () => EnsureConversationUsecase(repo: mockRepo)('user-99'), diff --git a/test/features/messaging/presentation/providers/block_unblock_notifier_test.dart b/test/features/messaging/presentation/providers/block_unblock_notifier_test.dart index b9cc05f0..06d4f1b0 100644 --- a/test/features/messaging/presentation/providers/block_unblock_notifier_test.dart +++ b/test/features/messaging/presentation/providers/block_unblock_notifier_test.dart @@ -42,10 +42,12 @@ void main() { setUp(() { mockRepo = MockMessagingRepository(); - when(() => mockRepo.getConversations()) - .thenAnswer((_) async => [tConversation]); - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async => ([tMessage], 1)); + when( + () => mockRepo.getConversations(), + ).thenAnswer((_) async => [tConversation]); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async => ([tMessage], 1)); when(() => mockRepo.isBlocked(any())).thenAnswer((_) async => false); }); @@ -159,10 +161,7 @@ void main() { final container = buildContainer(); addTearDown(container.dispose); - expect( - container.read(unBlockProvider.notifier), - isA(), - ); + expect(container.read(unBlockProvider.notifier), isA()); }); test('unBlockUser propagates through usecase to repo', () async { diff --git a/test/features/messaging/presentation/providers/mark_as_read_notifier_test.dart b/test/features/messaging/presentation/providers/mark_as_read_notifier_test.dart index bf8ff130..aa32ce1f 100644 --- a/test/features/messaging/presentation/providers/mark_as_read_notifier_test.dart +++ b/test/features/messaging/presentation/providers/mark_as_read_notifier_test.dart @@ -43,10 +43,12 @@ void main() { setUp(() { mockRepo = MockMessagingRepository(); - when(() => mockRepo.getConversations()) - .thenAnswer((_) async => [tConversation]); - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async => ([tMessage], 1)); + when( + () => mockRepo.getConversations(), + ).thenAnswer((_) async => [tConversation]); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async => ([tMessage], 1)); }); ProviderContainer buildContainer() { @@ -64,8 +66,9 @@ void main() { }); test('markRead calls repo.markMessageAsRead with correct args', () async { - when(() => mockRepo.markMessageAsRead(any(), any())) - .thenAnswer((_) async {}); + when( + () => mockRepo.markMessageAsRead(any(), any()), + ).thenAnswer((_) async {}); final container = buildContainer(); addTearDown(container.dispose); @@ -78,8 +81,9 @@ void main() { }); test('state returns to false after markRead completes', () async { - when(() => mockRepo.markMessageAsRead(any(), any())) - .thenAnswer((_) async {}); + when( + () => mockRepo.markMessageAsRead(any(), any()), + ).thenAnswer((_) async {}); final container = buildContainer(); addTearDown(container.dispose); @@ -91,24 +95,28 @@ void main() { expect(container.read(markAsRead), false); }); - test('state returns to false even when exception is thrown (409 case)', - () async { - when(() => mockRepo.markMessageAsRead(any(), any())) - .thenThrow(Exception('409: Already read')); + test( + 'state returns to false even when exception is thrown (409 case)', + () async { + when( + () => mockRepo.markMessageAsRead(any(), any()), + ).thenThrow(Exception('409: Already read')); - final container = buildContainer(); - addTearDown(container.dispose); + final container = buildContainer(); + addTearDown(container.dispose); - await container - .read(markAsRead.notifier) - .markRead(msgId: 'msg-1', convId: 'conv-1'); + await container + .read(markAsRead.notifier) + .markRead(msgId: 'msg-1', convId: 'conv-1'); - expect(container.read(markAsRead), false); - }); + expect(container.read(markAsRead), false); + }, + ); test('markRead calls updateMessageRead on the messages notifier', () async { - when(() => mockRepo.markMessageAsRead(any(), any())) - .thenAnswer((_) async {}); + when( + () => mockRepo.markMessageAsRead(any(), any()), + ).thenAnswer((_) async {}); final container = buildContainer(); addTearDown(container.dispose); @@ -128,26 +136,28 @@ void main() { final container = buildContainer(); addTearDown(container.dispose); - expect( - container.read(markAsRead.notifier), - isA(), - ); + expect(container.read(markAsRead.notifier), isA()); }); - test('markRead with different conversationId delegates correctly', () async { - when(() => mockRepo.markMessageAsRead(any(), any())) - .thenAnswer((_) async {}); - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async => ([], 0)); - - final container = buildContainer(); - addTearDown(container.dispose); - - await container - .read(markAsRead.notifier) - .markRead(msgId: 'msg-99', convId: 'conv-99'); - - verify(() => mockRepo.markMessageAsRead('msg-99', 'conv-99')).called(1); - }); + test( + 'markRead with different conversationId delegates correctly', + () async { + when( + () => mockRepo.markMessageAsRead(any(), any()), + ).thenAnswer((_) async {}); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async => ([], 0)); + + final container = buildContainer(); + addTearDown(container.dispose); + + await container + .read(markAsRead.notifier) + .markRead(msgId: 'msg-99', convId: 'conv-99'); + + verify(() => mockRepo.markMessageAsRead('msg-99', 'conv-99')).called(1); + }, + ); }); } diff --git a/test/features/messaging/presentation/providers/messages_notifier_test.dart b/test/features/messaging/presentation/providers/messages_notifier_test.dart index 1ecee7a3..4da06d42 100644 --- a/test/features/messaging/presentation/providers/messages_notifier_test.dart +++ b/test/features/messaging/presentation/providers/messages_notifier_test.dart @@ -20,13 +20,13 @@ void main() { /// Creates a minimal [Message] with the given [id], optionally marking it read. Message makeMessage(String id, {bool isRead = false}) => Message( - messageId: id, - senderId: 'user-1', - conversationId: 'conv-1', - body: 'Message $id', - isRead: isRead, - createdAt: tDate, - ); + messageId: id, + senderId: 'user-1', + conversationId: 'conv-1', + body: 'Message $id', + isRead: isRead, + createdAt: tDate, + ); setUp(() { mockRepo = MockMessagingRepository(); @@ -116,8 +116,9 @@ void main() { group('loadInitial — total <= 100', () { test('sets messages, isLoading=false, hasMore=false', () async { final messages = [makeMessage('m1'), makeMessage('m2')]; - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async => (messages, 2)); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async => (messages, 2)); final notifier = buildNotifier(); await Future.delayed(Duration.zero); @@ -130,8 +131,9 @@ void main() { test('works correctly with exactly 100 messages', () async { final messages = List.generate(100, (i) => makeMessage('m$i')); - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async => (messages, 100)); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async => (messages, 100)); final notifier = buildNotifier(); await Future.delayed(Duration.zero); @@ -142,56 +144,66 @@ void main() { }); group('loadInitial — total > 100', () { - test('fetches latest page and sets hasMore=true when latestOffset > 0', - () async { - final firstBatch = List.generate(100, (i) => makeMessage('first$i')); - final latestBatch = List.generate(50, (i) => makeMessage('latest$i')); - - when(() => mockRepo.getMessages(any(), offset: 0)) - .thenAnswer((_) async => (firstBatch, 150)); - when(() => mockRepo.getMessages(any(), offset: 50)) - .thenAnswer((_) async => (latestBatch, 150)); - - final notifier = buildNotifier(); - await Future.delayed(Duration.zero); - - expect(notifier.state.messages, latestBatch); - expect(notifier.state.isLoading, false); - expect(notifier.state.hasMore, true); - }); + test( + 'fetches latest page and sets hasMore=true when latestOffset > 0', + () async { + final firstBatch = List.generate(100, (i) => makeMessage('first$i')); + final latestBatch = List.generate(50, (i) => makeMessage('latest$i')); + + when( + () => mockRepo.getMessages(any(), offset: 0), + ).thenAnswer((_) async => (firstBatch, 150)); + when( + () => mockRepo.getMessages(any(), offset: 50), + ).thenAnswer((_) async => (latestBatch, 150)); + + final notifier = buildNotifier(); + await Future.delayed(Duration.zero); + + expect(notifier.state.messages, latestBatch); + expect(notifier.state.isLoading, false); + expect(notifier.state.hasMore, true); + }, + ); - test('sets hasMore=false when total is exactly 200 after one loadMore', - () async { - final firstBatch = List.generate(100, (i) => makeMessage('b1_$i')); - final latestBatch = List.generate(100, (i) => makeMessage('b2_$i')); + test( + 'sets hasMore=false when total is exactly 200 after one loadMore', + () async { + final firstBatch = List.generate(100, (i) => makeMessage('b1_$i')); + final latestBatch = List.generate(100, (i) => makeMessage('b2_$i')); - when(() => mockRepo.getMessages(any(), offset: 0)) - .thenAnswer((_) async => (firstBatch, 200)); - when(() => mockRepo.getMessages(any(), offset: 100)) - .thenAnswer((_) async => (latestBatch, 200)); + when( + () => mockRepo.getMessages(any(), offset: 0), + ).thenAnswer((_) async => (firstBatch, 200)); + when( + () => mockRepo.getMessages(any(), offset: 100), + ).thenAnswer((_) async => (latestBatch, 200)); - final notifier = buildNotifier(); - await Future.delayed(Duration.zero); + final notifier = buildNotifier(); + await Future.delayed(Duration.zero); - // latestOffset = 200 - 100 = 100, hasMore = (100 > 0) = true - expect(notifier.state.hasMore, true); + // latestOffset = 200 - 100 = 100, hasMore = (100 > 0) = true + expect(notifier.state.hasMore, true); - // After loadMore, olderOffset = max(0, 100-100) = 0, hasMore = false - when(() => mockRepo.getMessages(any(), offset: 0)) - .thenAnswer((_) async => (firstBatch, 200)); + // After loadMore, olderOffset = max(0, 100-100) = 0, hasMore = false + when( + () => mockRepo.getMessages(any(), offset: 0), + ).thenAnswer((_) async => (firstBatch, 200)); - await notifier.loadMore(); + await notifier.loadMore(); - expect(notifier.state.hasMore, false); - expect(notifier.state.messages.first.messageId, 'b1_0'); - expect(notifier.state.messages.last.messageId, 'b2_99'); - }); + expect(notifier.state.hasMore, false); + expect(notifier.state.messages.first.messageId, 'b1_0'); + expect(notifier.state.messages.last.messageId, 'b2_99'); + }, + ); }); group('loadInitial — error handling', () { test('sets error state and isLoading=false on exception', () async { - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenThrow(Exception('Network error')); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenThrow(Exception('Network error')); final notifier = buildNotifier(); await Future.delayed(Duration.zero); @@ -203,8 +215,9 @@ void main() { test('starts with isLoading=true before async resolves', () { // Stub getMessages to never complete - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async { + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async { await Future.delayed(const Duration(seconds: 10)); return ([], 0); }); @@ -219,17 +232,20 @@ void main() { final latestBatch = [makeMessage('latest')]; final olderBatch = [makeMessage('older')]; - when(() => mockRepo.getMessages(any(), offset: 0)) - .thenAnswer((_) async => (latestBatch, 150)); - when(() => mockRepo.getMessages(any(), offset: 50)) - .thenAnswer((_) async => (latestBatch, 150)); + when( + () => mockRepo.getMessages(any(), offset: 0), + ).thenAnswer((_) async => (latestBatch, 150)); + when( + () => mockRepo.getMessages(any(), offset: 50), + ).thenAnswer((_) async => (latestBatch, 150)); final notifier = buildNotifier(); await Future.delayed(Duration.zero); expect(notifier.state.hasMore, true); - when(() => mockRepo.getMessages(any(), offset: 0)) - .thenAnswer((_) async => (olderBatch, 150)); + when( + () => mockRepo.getMessages(any(), offset: 0), + ).thenAnswer((_) async => (olderBatch, 150)); await notifier.loadMore(); @@ -240,8 +256,9 @@ void main() { }); test('does nothing when hasMore is false', () async { - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async => ([makeMessage('m1')], 1)); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async => ([makeMessage('m1')], 1)); final notifier = buildNotifier(); await Future.delayed(Duration.zero); @@ -250,23 +267,25 @@ void main() { clearInteractions(mockRepo); await notifier.loadMore(); - verifyNever(() => mockRepo.getMessages( - any(), - offset: any(named: 'offset'), - )); + verifyNever( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ); }); test('sets isLoadingMore to false on exception during load', () async { - when(() => mockRepo.getMessages(any(), offset: 0)) - .thenAnswer((_) async => ([makeMessage('m1')], 150)); - when(() => mockRepo.getMessages(any(), offset: 50)) - .thenAnswer((_) async => ([makeMessage('m2')], 150)); + when( + () => mockRepo.getMessages(any(), offset: 0), + ).thenAnswer((_) async => ([makeMessage('m1')], 150)); + when( + () => mockRepo.getMessages(any(), offset: 50), + ).thenAnswer((_) async => ([makeMessage('m2')], 150)); final notifier = buildNotifier(); await Future.delayed(Duration.zero); - when(() => mockRepo.getMessages(any(), offset: 0)) - .thenThrow(Exception('Load failed')); + when( + () => mockRepo.getMessages(any(), offset: 0), + ).thenThrow(Exception('Load failed')); await notifier.loadMore(); @@ -276,15 +295,17 @@ void main() { group('refresh', () { test('re-runs loadInitial and resets to latest page', () async { - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async => ([makeMessage('m1')], 1)); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async => ([makeMessage('m1')], 1)); final notifier = buildNotifier(); await Future.delayed(Duration.zero); expect(notifier.state.messages.first.messageId, 'm1'); - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async => ([makeMessage('m2')], 1)); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async => ([makeMessage('m2')], 1)); await notifier.refresh(); @@ -297,8 +318,9 @@ void main() { final m1 = makeMessage('m1'); final m2 = makeMessage('m2'); - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async => ([m1], 1)); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async => ([m1], 1)); final notifier = buildNotifier(); await Future.delayed(Duration.zero); @@ -309,8 +331,9 @@ void main() { }); test('appends to empty list', () async { - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async => ([], 0)); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async => ([], 0)); final notifier = buildNotifier(); await Future.delayed(Duration.zero); @@ -328,8 +351,9 @@ void main() { final m2 = makeMessage('m2', isRead: false); final m3 = makeMessage('m3', isRead: false); - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async => ([m1, m2, m3], 3)); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async => ([m1, m2, m3], 3)); final notifier = buildNotifier(); await Future.delayed(Duration.zero); @@ -344,8 +368,9 @@ void main() { test('does not change anything for non-existing messageId', () async { final m1 = makeMessage('m1', isRead: false); - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async => ([m1], 1)); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async => ([m1], 1)); final notifier = buildNotifier(); await Future.delayed(Duration.zero); @@ -358,8 +383,9 @@ void main() { test('can mark an already-read message (no-op on isRead)', () async { final m1 = makeMessage('m1', isRead: true); - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async => ([m1], 1)); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async => ([m1], 1)); final notifier = buildNotifier(); await Future.delayed(Duration.zero); diff --git a/test/features/messaging/presentation/providers/messaging_providers_wiring_test.dart b/test/features/messaging/presentation/providers/messaging_providers_wiring_test.dart index ea9b505a..2814dc11 100644 --- a/test/features/messaging/presentation/providers/messaging_providers_wiring_test.dart +++ b/test/features/messaging/presentation/providers/messaging_providers_wiring_test.dart @@ -41,18 +41,22 @@ void main() { } group('conversationProvider wiring', () { - test('executes GetConversationsUsecase and returns conversations', () async { - when(() => mockRepo.getConversations()) - .thenAnswer((_) async => [tConversation]); + test( + 'executes GetConversationsUsecase and returns conversations', + () async { + when( + () => mockRepo.getConversations(), + ).thenAnswer((_) async => [tConversation]); - final container = buildContainer(); - addTearDown(container.dispose); + final container = buildContainer(); + addTearDown(container.dispose); - final result = await container.read(conversationProvider.future); + final result = await container.read(conversationProvider.future); - expect(result, [tConversation]); - verify(() => mockRepo.getConversations()).called(1); - }); + expect(result, [tConversation]); + verify(() => mockRepo.getConversations()).called(1); + }, + ); test('returns empty list when repo has no conversations', () async { when(() => mockRepo.getConversations()).thenAnswer((_) async => []); @@ -73,8 +77,7 @@ void main() { final container = buildContainer(); addTearDown(container.dispose); - final result = - await container.read(isBlockedProvider('user-2').future); + final result = await container.read(isBlockedProvider('user-2').future); expect(result, true); verify(() => mockRepo.isBlocked('user-2')).called(1); @@ -86,26 +89,29 @@ void main() { final container = buildContainer(); addTearDown(container.dispose); - final result = - await container.read(isBlockedProvider('user-3').future); + final result = await container.read(isBlockedProvider('user-3').future); expect(result, false); }); }); group('isBlockedByProvider wiring', () { - test('executes IsBlockedByUsecase and returns true when blocked by', () async { - when(() => mockRepo.isBlockedBy(any())).thenAnswer((_) async => true); + test( + 'executes IsBlockedByUsecase and returns true when blocked by', + () async { + when(() => mockRepo.isBlockedBy(any())).thenAnswer((_) async => true); - final container = buildContainer(); - addTearDown(container.dispose); + final container = buildContainer(); + addTearDown(container.dispose); - final result = - await container.read(isBlockedByProvider('user-2').future); + final result = await container.read( + isBlockedByProvider('user-2').future, + ); - expect(result, true); - verify(() => mockRepo.isBlockedBy('user-2')).called(1); - }); + expect(result, true); + verify(() => mockRepo.isBlockedBy('user-2')).called(1); + }, + ); test('returns false when not blocked by participant', () async { when(() => mockRepo.isBlockedBy(any())).thenAnswer((_) async => false); @@ -113,8 +119,7 @@ void main() { final container = buildContainer(); addTearDown(container.dispose); - final result = - await container.read(isBlockedByProvider('user-3').future); + final result = await container.read(isBlockedByProvider('user-3').future); expect(result, false); }); diff --git a/test/features/messaging/presentation/providers/send_message_notifier_test.dart b/test/features/messaging/presentation/providers/send_message_notifier_test.dart index 63e554a2..f71c8b7a 100644 --- a/test/features/messaging/presentation/providers/send_message_notifier_test.dart +++ b/test/features/messaging/presentation/providers/send_message_notifier_test.dart @@ -54,11 +54,13 @@ void main() { when(() => mockSocket.sendMessage(any(), any())).thenReturn(null); - when(() => mockRepo.getMessages(any(), offset: any(named: 'offset'))) - .thenAnswer((_) async => ([tMessage], 1)); + when( + () => mockRepo.getMessages(any(), offset: any(named: 'offset')), + ).thenAnswer((_) async => ([tMessage], 1)); - when(() => mockRepo.getConversations()) - .thenAnswer((_) async => [tConversation]); + when( + () => mockRepo.getConversations(), + ).thenAnswer((_) async => [tConversation]); }); ProviderContainer buildContainer() { @@ -80,8 +82,9 @@ void main() { group('sendMessage — with existing conversationId', () { test('calls repo.sendMessage and returns null', () async { - when(() => mockRepo.sendMessage(any(), any(), any(), any())) - .thenAnswer((_) async => tMessage); + when( + () => mockRepo.sendMessage(any(), any(), any(), any()), + ).thenAnswer((_) async => tMessage); final container = buildContainer(); addTearDown(container.dispose); @@ -91,36 +94,37 @@ void main() { .sendMessage(conversationId: 'conv-1', body: 'Hello'); expect(result, isNull); - verify(() => mockRepo.sendMessage('conv-1', 'Hello', null, null)) - .called(1); + verify( + () => mockRepo.sendMessage('conv-1', 'Hello', null, null), + ).called(1); }); test('state returns to false after completion', () async { - when(() => mockRepo.sendMessage(any(), any(), any(), any())) - .thenAnswer((_) async => tMessage); + when( + () => mockRepo.sendMessage(any(), any(), any(), any()), + ).thenAnswer((_) async => tMessage); final container = buildContainer(); addTearDown(container.dispose); - await container.read(sendMessageProvider.notifier).sendMessage( - conversationId: 'conv-1', - body: 'Hello', - ); + await container + .read(sendMessageProvider.notifier) + .sendMessage(conversationId: 'conv-1', body: 'Hello'); expect(container.read(sendMessageProvider), false); }); test('calls socket.sendMessage with conversation data', () async { - when(() => mockRepo.sendMessage(any(), any(), any(), any())) - .thenAnswer((_) async => tMessage); + when( + () => mockRepo.sendMessage(any(), any(), any(), any()), + ).thenAnswer((_) async => tMessage); final container = buildContainer(); addTearDown(container.dispose); - await container.read(sendMessageProvider.notifier).sendMessage( - conversationId: 'conv-1', - body: 'Hello', - ); + await container + .read(sendMessageProvider.notifier) + .sendMessage(conversationId: 'conv-1', body: 'Hello'); verify(() => mockSocket.sendMessage('conv-1', any())).called(1); }); @@ -135,104 +139,115 @@ void main() { isRead: false, createdAt: tDate, ); - when(() => mockRepo.sendMessage(any(), any(), any(), any())) - .thenAnswer((_) async => embedMessage); + when( + () => mockRepo.sendMessage(any(), any(), any(), any()), + ).thenAnswer((_) async => embedMessage); final container = buildContainer(); addTearDown(container.dispose); - await container.read(sendMessageProvider.notifier).sendMessage( + await container + .read(sendMessageProvider.notifier) + .sendMessage( conversationId: 'conv-1', embedId: 'track-1', embedType: 'track', ); - verify(() => mockRepo.sendMessage('conv-1', null, 'track-1', 'track')) - .called(1); + verify( + () => mockRepo.sendMessage('conv-1', null, 'track-1', 'track'), + ).called(1); }); }); group('sendMessage — without conversationId (new conversation)', () { - test('calls repo.startConversation and returns new conversation', () async { - when(() => mockRepo.startConversation(any(), any(), any(), any())) - .thenAnswer((_) async => tConversation); - - final container = buildContainer(); - addTearDown(container.dispose); - - final result = await container - .read(sendMessageProvider.notifier) - .sendMessage( - newParticipantId: 'user-2', - body: 'Hello', - ); - - expect(result, tConversation); - verify(() => mockRepo.startConversation('user-2', 'Hello', null, null)) - .called(1); - }); + test( + 'calls repo.startConversation and returns new conversation', + () async { + when( + () => mockRepo.startConversation(any(), any(), any(), any()), + ).thenAnswer((_) async => tConversation); + + final container = buildContainer(); + addTearDown(container.dispose); + + final result = await container + .read(sendMessageProvider.notifier) + .sendMessage(newParticipantId: 'user-2', body: 'Hello'); + + expect(result, tConversation); + verify( + () => mockRepo.startConversation('user-2', 'Hello', null, null), + ).called(1); + }, + ); test('routes track embedId to trackId', () async { - when(() => mockRepo.startConversation(any(), any(), any(), any())) - .thenAnswer((_) async => tConversation); + when( + () => mockRepo.startConversation(any(), any(), any(), any()), + ).thenAnswer((_) async => tConversation); final container = buildContainer(); addTearDown(container.dispose); - await container.read(sendMessageProvider.notifier).sendMessage( + await container + .read(sendMessageProvider.notifier) + .sendMessage( newParticipantId: 'user-2', embedId: 'track-123', embedType: 'track', ); - verify(() => mockRepo.startConversation( - 'user-2', - null, - 'track-123', - null, - )).called(1); + verify( + () => mockRepo.startConversation('user-2', null, 'track-123', null), + ).called(1); }); test('routes playlist embedId to playlistId', () async { - when(() => mockRepo.startConversation(any(), any(), any(), any())) - .thenAnswer((_) async => tConversation); + when( + () => mockRepo.startConversation(any(), any(), any(), any()), + ).thenAnswer((_) async => tConversation); final container = buildContainer(); addTearDown(container.dispose); - await container.read(sendMessageProvider.notifier).sendMessage( + await container + .read(sendMessageProvider.notifier) + .sendMessage( newParticipantId: 'user-2', embedId: 'pl-456', embedType: 'playlist', ); - verify(() => mockRepo.startConversation( - 'user-2', - null, - null, - 'pl-456', - )).called(1); + verify( + () => mockRepo.startConversation('user-2', null, null, 'pl-456'), + ).called(1); }); - test('state returns to false after new conversation is created', () async { - when(() => mockRepo.startConversation(any(), any(), any(), any())) - .thenAnswer((_) async => tConversation); + test( + 'state returns to false after new conversation is created', + () async { + when( + () => mockRepo.startConversation(any(), any(), any(), any()), + ).thenAnswer((_) async => tConversation); - final container = buildContainer(); - addTearDown(container.dispose); + final container = buildContainer(); + addTearDown(container.dispose); - await container.read(sendMessageProvider.notifier).sendMessage( - newParticipantId: 'user-2', - ); + await container + .read(sendMessageProvider.notifier) + .sendMessage(newParticipantId: 'user-2'); - expect(container.read(sendMessageProvider), false); - }); + expect(container.read(sendMessageProvider), false); + }, + ); }); group('ensureConversation', () { test('calls repo.ensureConversation and returns conversation', () async { - when(() => mockRepo.ensureConversation(any())) - .thenAnswer((_) async => tConversation); + when( + () => mockRepo.ensureConversation(any()), + ).thenAnswer((_) async => tConversation); final container = buildContainer(); addTearDown(container.dispose); @@ -246,8 +261,9 @@ void main() { }); test('state returns to false after completion', () async { - when(() => mockRepo.ensureConversation(any())) - .thenAnswer((_) async => tConversation); + when( + () => mockRepo.ensureConversation(any()), + ).thenAnswer((_) async => tConversation); final container = buildContainer(); addTearDown(container.dispose); diff --git a/test/features/settings/data/datasources/settings_datasource_impl_test.dart b/test/features/settings/data/datasources/settings_datasource_impl_test.dart index 857c4fdd..6e9a69e6 100644 --- a/test/features/settings/data/datasources/settings_datasource_impl_test.dart +++ b/test/features/settings/data/datasources/settings_datasource_impl_test.dart @@ -106,16 +106,21 @@ void main() { group('SettingsDatasourcesImpl', () { group('getPrivacySettings', () { - test('performs GET /users/me/privacy-settings and parses response', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _privacyResponse()); - - final result = await datasource.getPrivacySettings(); - - expect(result, isA()); - expect(result.isPrivate, false); - expect(result.receiveMessageFromAnyone, true); - verify(() => mockDio.get('/users/me/privacy-settings')).called(1); - }); + test( + 'performs GET /users/me/privacy-settings and parses response', + () async { + when( + () => mockDio.get(any()), + ).thenAnswer((_) async => _privacyResponse()); + + final result = await datasource.getPrivacySettings(); + + expect(result, isA()); + expect(result.isPrivate, false); + expect(result.receiveMessageFromAnyone, true); + verify(() => mockDio.get('/users/me/privacy-settings')).called(1); + }, + ); test('returns defaults when response data is null', () async { when(() => mockDio.get(any())).thenAnswer( @@ -149,22 +154,25 @@ void main() { }); group('patchPrivacySettings', () { - test('performs PATCH /users/me/privacy-settings with given fields', () async { - const fields = {'receive_messages_from_anyone': false}; - - when(() => mockDio.patch(any(), data: any(named: 'data'))) - .thenAnswer((_) async => _privacyResponse()); - - final result = await datasource.patchPrivacySettings(fields); - - expect(result, isA()); - verify( - () => mockDio.patch( - '/users/me/privacy-settings', - data: fields, - ), - ).called(1); - }); + test( + 'performs PATCH /users/me/privacy-settings with given fields', + () async { + const fields = { + 'receive_messages_from_anyone': false, + }; + + when( + () => mockDio.patch(any(), data: any(named: 'data')), + ).thenAnswer((_) async => _privacyResponse()); + + final result = await datasource.patchPrivacySettings(fields); + + expect(result, isA()); + verify( + () => mockDio.patch('/users/me/privacy-settings', data: fields), + ).called(1); + }, + ); test('returns defaults when patch response data is null', () async { when(() => mockDio.patch(any(), data: any(named: 'data'))).thenAnswer( @@ -196,37 +204,50 @@ void main() { }); group('getNotificationPreferences', () { - test('performs GET /notifications/preferences and parses response', () async { - when(() => mockDio.get(any())).thenAnswer((_) async => _notifResponse()); - - final result = await datasource.getNotificationPreferences(); - - expect(result, isA()); - expect(result.newFollowerPush, true); - expect(result.messagesFrom, MessagesFrom.everyone); - verify(() => mockDio.get('/notifications/preferences')).called(1); - }); + test( + 'performs GET /notifications/preferences and parses response', + () async { + when( + () => mockDio.get(any()), + ).thenAnswer((_) async => _notifResponse()); + + final result = await datasource.getNotificationPreferences(); + + expect(result, isA()); + expect(result.newFollowerPush, true); + expect(result.messagesFrom, MessagesFrom.everyone); + verify(() => mockDio.get('/notifications/preferences')).called(1); + }, + ); }); group('updateNotificationPreferences', () { - test('performs PATCH /notifications/preferences with serialised model', () async { - when(() => mockDio.patch(any(), data: any(named: 'data'))) - .thenAnswer((_) async => _notifResponse()); - - final result = await datasource.updateNotificationPreferences(tNotifModel); - - expect(result, isA()); - expect(result.messagesFrom, MessagesFrom.everyone); - - final captured = verify( - () => mockDio.patch( - '/notifications/preferences', - data: captureAny(named: 'data'), - ), - ).captured.single as Map; - expect(captured['new_follower_push'], true); - expect(captured['messages_from'], 'everyone'); - }); + test( + 'performs PATCH /notifications/preferences with serialised model', + () async { + when( + () => mockDio.patch(any(), data: any(named: 'data')), + ).thenAnswer((_) async => _notifResponse()); + + final result = await datasource.updateNotificationPreferences( + tNotifModel, + ); + + expect(result, isA()); + expect(result.messagesFrom, MessagesFrom.everyone); + + final captured = + verify( + () => mockDio.patch( + '/notifications/preferences', + data: captureAny(named: 'data'), + ), + ).captured.single + as Map; + expect(captured['new_follower_push'], true); + expect(captured['messages_from'], 'everyone'); + }, + ); }); group('deleteMyAccount', () { diff --git a/test/features/settings/data/models/notification_preferences_model_test.dart b/test/features/settings/data/models/notification_preferences_model_test.dart index fd557d57..257ee4cf 100644 --- a/test/features/settings/data/models/notification_preferences_model_test.dart +++ b/test/features/settings/data/models/notification_preferences_model_test.dart @@ -123,37 +123,37 @@ void main() { }); test('parses messages_from = everyone', () { - final model = NotificationPreferencesModel.fromJson( - {'messages_from': 'everyone'}, - ); + final model = NotificationPreferencesModel.fromJson({ + 'messages_from': 'everyone', + }); expect(model.messagesFrom, MessagesFrom.everyone); }); test('parses messages_from = followers_only', () { - final model = NotificationPreferencesModel.fromJson( - {'messages_from': 'followers_only'}, - ); + final model = NotificationPreferencesModel.fromJson({ + 'messages_from': 'followers_only', + }); expect(model.messagesFrom, MessagesFrom.followersOnly); }); test('parses messages_from = nobody', () { - final model = NotificationPreferencesModel.fromJson( - {'messages_from': 'nobody'}, - ); + final model = NotificationPreferencesModel.fromJson({ + 'messages_from': 'nobody', + }); expect(model.messagesFrom, MessagesFrom.nobody); }); test('defaults to everyone for an unrecognised messages_from string', () { - final model = NotificationPreferencesModel.fromJson( - {'messages_from': 'some_unknown_value'}, - ); + final model = NotificationPreferencesModel.fromJson({ + 'messages_from': 'some_unknown_value', + }); expect(model.messagesFrom, MessagesFrom.everyone); }); test('defaults to everyone when messages_from is null', () { - final model = NotificationPreferencesModel.fromJson( - {'messages_from': null}, - ); + final model = NotificationPreferencesModel.fromJson({ + 'messages_from': null, + }); expect(model.messagesFrom, MessagesFrom.everyone); }); }); @@ -189,23 +189,23 @@ void main() { }); test('serialises messages_from = everyone', () { - final model = NotificationPreferencesModel.fromJson( - {'messages_from': 'everyone'}, - ); + final model = NotificationPreferencesModel.fromJson({ + 'messages_from': 'everyone', + }); expect(model.toJson()['messages_from'], 'everyone'); }); test('serialises messages_from = followers_only', () { - final model = NotificationPreferencesModel.fromJson( - {'messages_from': 'followers_only'}, - ); + final model = NotificationPreferencesModel.fromJson({ + 'messages_from': 'followers_only', + }); expect(model.toJson()['messages_from'], 'followers_only'); }); test('serialises messages_from = nobody', () { - final model = NotificationPreferencesModel.fromJson( - {'messages_from': 'nobody'}, - ); + final model = NotificationPreferencesModel.fromJson({ + 'messages_from': 'nobody', + }); expect(model.toJson()['messages_from'], 'nobody'); }); }); diff --git a/test/features/settings/data/models/privacy_settings_model_test.dart b/test/features/settings/data/models/privacy_settings_model_test.dart index c771cfba..33cbca70 100644 --- a/test/features/settings/data/models/privacy_settings_model_test.dart +++ b/test/features/settings/data/models/privacy_settings_model_test.dart @@ -61,16 +61,16 @@ void main() { }); test('defaults receiveMessageFromAnyone to true when null', () { - final model = PrivacySettingsModel.fromJson( - {'receive_messages_from_anyone': null}, - ); + final model = PrivacySettingsModel.fromJson({ + 'receive_messages_from_anyone': null, + }); expect(model.receiveMessageFromAnyone, true); }); test('defaults showActivitiesInDiscovery to true when null', () { - final model = PrivacySettingsModel.fromJson( - {'show_activities_in_discovery': null}, - ); + final model = PrivacySettingsModel.fromJson({ + 'show_activities_in_discovery': null, + }); expect(model.showActivitiesInDiscovery, true); }); @@ -80,9 +80,9 @@ void main() { }); test('defaults showTopFansOnTracks to true when null', () { - final model = PrivacySettingsModel.fromJson( - {'show_top_fans_on_tracks': null}, - ); + final model = PrivacySettingsModel.fromJson({ + 'show_top_fans_on_tracks': null, + }); expect(model.showTopFansOnTracks, true); }); }); @@ -191,8 +191,14 @@ void main() { final model = PrivacySettingsModel.fromEntity(tEntity); expect(model.isPrivate, tEntity.isPrivate); - expect(model.receiveMessageFromAnyone, tEntity.receiveMessageFromAnyone); - expect(model.showActivitiesInDiscovery, tEntity.showActivitiesInDiscovery); + expect( + model.receiveMessageFromAnyone, + tEntity.receiveMessageFromAnyone, + ); + expect( + model.showActivitiesInDiscovery, + tEntity.showActivitiesInDiscovery, + ); expect(model.showAsTopFan, tEntity.showAsTopFan); expect(model.showTopFansOnTracks, tEntity.showTopFansOnTracks); }); @@ -204,8 +210,14 @@ void main() { expect(entity, isA()); expect(entity.isPrivate, tModel.isPrivate); - expect(entity.receiveMessageFromAnyone, tModel.receiveMessageFromAnyone); - expect(entity.showActivitiesInDiscovery, tModel.showActivitiesInDiscovery); + expect( + entity.receiveMessageFromAnyone, + tModel.receiveMessageFromAnyone, + ); + expect( + entity.showActivitiesInDiscovery, + tModel.showActivitiesInDiscovery, + ); expect(entity.showAsTopFan, tModel.showAsTopFan); expect(entity.showTopFansOnTracks, tModel.showTopFansOnTracks); }); diff --git a/test/features/settings/data/repositories/settings_repo_impl_test.dart b/test/features/settings/data/repositories/settings_repo_impl_test.dart index c2fc222f..46303c23 100644 --- a/test/features/settings/data/repositories/settings_repo_impl_test.dart +++ b/test/features/settings/data/repositories/settings_repo_impl_test.dart @@ -90,25 +90,36 @@ void main() { group('SettingsRepoImpl', () { group('getPrivacySettings', () { - test('returns PrivacySettingsEntity mapped from datasource model', () async { - when(() => mockDatasource.getPrivacySettings()) - .thenAnswer((_) async => tPrivacyModel); - - final result = await repo.getPrivacySettings(); - - expect(result, isA()); - expect(result.isPrivate, tPrivacyModel.isPrivate); - expect(result.receiveMessageFromAnyone, tPrivacyModel.receiveMessageFromAnyone); - expect(result.showActivitiesInDiscovery, tPrivacyModel.showActivitiesInDiscovery); - expect(result.showAsTopFan, tPrivacyModel.showAsTopFan); - expect(result.showTopFansOnTracks, tPrivacyModel.showTopFansOnTracks); - verify(() => mockDatasource.getPrivacySettings()).called(1); - verifyNoMoreInteractions(mockDatasource); - }); + test( + 'returns PrivacySettingsEntity mapped from datasource model', + () async { + when( + () => mockDatasource.getPrivacySettings(), + ).thenAnswer((_) async => tPrivacyModel); + + final result = await repo.getPrivacySettings(); + + expect(result, isA()); + expect(result.isPrivate, tPrivacyModel.isPrivate); + expect( + result.receiveMessageFromAnyone, + tPrivacyModel.receiveMessageFromAnyone, + ); + expect( + result.showActivitiesInDiscovery, + tPrivacyModel.showActivitiesInDiscovery, + ); + expect(result.showAsTopFan, tPrivacyModel.showAsTopFan); + expect(result.showTopFansOnTracks, tPrivacyModel.showTopFansOnTracks); + verify(() => mockDatasource.getPrivacySettings()).called(1); + verifyNoMoreInteractions(mockDatasource); + }, + ); test('propagates exceptions from datasource', () { - when(() => mockDatasource.getPrivacySettings()) - .thenThrow(Exception('Network error')); + when( + () => mockDatasource.getPrivacySettings(), + ).thenThrow(Exception('Network error')); expect(() => repo.getPrivacySettings(), throwsException); }); @@ -123,56 +134,71 @@ void main() { showTopFansOnTracks: true, ); - test('returns updated entity without calling datasource when nothing changed', () async { - const updated = previous; + test( + 'returns updated entity without calling datasource when nothing changed', + () async { + const updated = previous; - final result = await repo.updatePrivacySettings(previous, updated); - - expect(result, updated); - verifyNever(() => mockDatasource.patchPrivacySettings(any())); - }); - - test('calls patchPrivacySettings when receiveMessageFromAnyone changes', () async { - const updated = PrivacySettingsEntity( - isPrivate: false, - receiveMessageFromAnyone: false, - showActivitiesInDiscovery: true, - showAsTopFan: true, - showTopFansOnTracks: true, - ); - - when(() => mockDatasource.patchPrivacySettings(any())) - .thenAnswer((_) async => tPrivacyModel); - - final result = await repo.updatePrivacySettings(previous, updated); - - expect(result, isA()); - final captured = verify( - () => mockDatasource.patchPrivacySettings(captureAny()), - ).captured.single as Map; - expect(captured['receive_messages_from_anyone'], false); - expect(captured.containsKey('is_private'), false); - }); + final result = await repo.updatePrivacySettings(previous, updated); - test('calls patchPrivacySettings when showActivitiesInDiscovery changes', () async { - const updated = PrivacySettingsEntity( - isPrivate: false, - receiveMessageFromAnyone: true, - showActivitiesInDiscovery: false, - showAsTopFan: true, - showTopFansOnTracks: true, - ); - - when(() => mockDatasource.patchPrivacySettings(any())) - .thenAnswer((_) async => tPrivacyModel); + expect(result, updated); + verifyNever(() => mockDatasource.patchPrivacySettings(any())); + }, + ); - await repo.updatePrivacySettings(previous, updated); + test( + 'calls patchPrivacySettings when receiveMessageFromAnyone changes', + () async { + const updated = PrivacySettingsEntity( + isPrivate: false, + receiveMessageFromAnyone: false, + showActivitiesInDiscovery: true, + showAsTopFan: true, + showTopFansOnTracks: true, + ); + + when( + () => mockDatasource.patchPrivacySettings(any()), + ).thenAnswer((_) async => tPrivacyModel); + + final result = await repo.updatePrivacySettings(previous, updated); + + expect(result, isA()); + final captured = + verify( + () => mockDatasource.patchPrivacySettings(captureAny()), + ).captured.single + as Map; + expect(captured['receive_messages_from_anyone'], false); + expect(captured.containsKey('is_private'), false); + }, + ); - final captured = verify( - () => mockDatasource.patchPrivacySettings(captureAny()), - ).captured.single as Map; - expect(captured['show_activities_in_discovery'], false); - }); + test( + 'calls patchPrivacySettings when showActivitiesInDiscovery changes', + () async { + const updated = PrivacySettingsEntity( + isPrivate: false, + receiveMessageFromAnyone: true, + showActivitiesInDiscovery: false, + showAsTopFan: true, + showTopFansOnTracks: true, + ); + + when( + () => mockDatasource.patchPrivacySettings(any()), + ).thenAnswer((_) async => tPrivacyModel); + + await repo.updatePrivacySettings(previous, updated); + + final captured = + verify( + () => mockDatasource.patchPrivacySettings(captureAny()), + ).captured.single + as Map; + expect(captured['show_activities_in_discovery'], false); + }, + ); test('calls patchPrivacySettings when showAsTopFan changes', () async { const updated = PrivacySettingsEntity( @@ -183,14 +209,17 @@ void main() { showTopFansOnTracks: true, ); - when(() => mockDatasource.patchPrivacySettings(any())) - .thenAnswer((_) async => tPrivacyModel); + when( + () => mockDatasource.patchPrivacySettings(any()), + ).thenAnswer((_) async => tPrivacyModel); await repo.updatePrivacySettings(previous, updated); - final captured = verify( - () => mockDatasource.patchPrivacySettings(captureAny()), - ).captured.single as Map; + final captured = + verify( + () => mockDatasource.patchPrivacySettings(captureAny()), + ).captured.single + as Map; expect(captured['show_as_top_fan'], false); }); @@ -203,8 +232,9 @@ void main() { showTopFansOnTracks: true, ); - when(() => mockDatasource.patchPrivacySettings(any())) - .thenThrow(Exception('Server error')); + when( + () => mockDatasource.patchPrivacySettings(any()), + ).thenThrow(Exception('Server error')); expect( () => repo.updatePrivacySettings(previous, updated), @@ -215,8 +245,9 @@ void main() { group('getNotificationPreferences', () { test('returns NotificationPreferencesEntity from datasource', () async { - when(() => mockDatasource.getNotificationPreferences()) - .thenAnswer((_) async => tNotifModel); + when( + () => mockDatasource.getNotificationPreferences(), + ).thenAnswer((_) async => tNotifModel); final result = await repo.getNotificationPreferences(); @@ -229,8 +260,9 @@ void main() { }); test('propagates exceptions from datasource', () { - when(() => mockDatasource.getNotificationPreferences()) - .thenThrow(Exception('Timeout')); + when( + () => mockDatasource.getNotificationPreferences(), + ).thenThrow(Exception('Timeout')); expect(() => repo.getNotificationPreferences(), throwsException); }); @@ -238,34 +270,46 @@ void main() { group('updateNotificationPreferences', () { test('calls datasource and returns mapped entity', () async { - when(() => mockDatasource.updateNotificationPreferences(any())) - .thenAnswer((_) async => tNotifModel); + when( + () => mockDatasource.updateNotificationPreferences(any()), + ).thenAnswer((_) async => tNotifModel); final result = await repo.updateNotificationPreferences(tNotifEntity); expect(result, isA()); expect(result.newFollowerPush, tNotifModel.newFollowerPush); expect(result.messagesFrom, tNotifModel.messagesFrom); - verify(() => mockDatasource.updateNotificationPreferences(any())).called(1); + verify( + () => mockDatasource.updateNotificationPreferences(any()), + ).called(1); verifyNoMoreInteractions(mockDatasource); }); - test('passes a model built from the given entity to datasource', () async { - when(() => mockDatasource.updateNotificationPreferences(any())) - .thenAnswer((_) async => tNotifModel); - - await repo.updateNotificationPreferences(tNotifEntity); - - final captured = verify( - () => mockDatasource.updateNotificationPreferences(captureAny()), - ).captured.single as NotificationPreferencesModel; - expect(captured.newFollowerPush, tNotifEntity.newFollowerPush); - expect(captured.messagesFrom, tNotifEntity.messagesFrom); - }); + test( + 'passes a model built from the given entity to datasource', + () async { + when( + () => mockDatasource.updateNotificationPreferences(any()), + ).thenAnswer((_) async => tNotifModel); + + await repo.updateNotificationPreferences(tNotifEntity); + + final captured = + verify( + () => mockDatasource.updateNotificationPreferences( + captureAny(), + ), + ).captured.single + as NotificationPreferencesModel; + expect(captured.newFollowerPush, tNotifEntity.newFollowerPush); + expect(captured.messagesFrom, tNotifEntity.messagesFrom); + }, + ); test('propagates exceptions from datasource', () { - when(() => mockDatasource.updateNotificationPreferences(any())) - .thenThrow(Exception('Bad request')); + when( + () => mockDatasource.updateNotificationPreferences(any()), + ).thenThrow(Exception('Bad request')); expect( () => repo.updateNotificationPreferences(tNotifEntity), @@ -276,8 +320,7 @@ void main() { group('deleteMyAccount', () { test('delegates to datasource.deleteMyAccount', () async { - when(() => mockDatasource.deleteMyAccount()) - .thenAnswer((_) async {}); + when(() => mockDatasource.deleteMyAccount()).thenAnswer((_) async {}); await repo.deleteMyAccount(); @@ -286,8 +329,9 @@ void main() { }); test('propagates exceptions from datasource', () { - when(() => mockDatasource.deleteMyAccount()) - .thenThrow(Exception('Forbidden')); + when( + () => mockDatasource.deleteMyAccount(), + ).thenThrow(Exception('Forbidden')); expect(() => repo.deleteMyAccount(), throwsException); }); diff --git a/test/features/settings/domain/entities/notification_preferences_entity_test.dart b/test/features/settings/domain/entities/notification_preferences_entity_test.dart index 42cd1b1a..c5ad04a2 100644 --- a/test/features/settings/domain/entities/notification_preferences_entity_test.dart +++ b/test/features/settings/domain/entities/notification_preferences_entity_test.dart @@ -149,7 +149,9 @@ void main() { }); test('overrides messagesFrom to followersOnly', () { - final updated = tEntity.copyWith(messagesFrom: MessagesFrom.followersOnly); + final updated = tEntity.copyWith( + messagesFrom: MessagesFrom.followersOnly, + ); expect(updated.messagesFrom, MessagesFrom.followersOnly); }); @@ -213,11 +215,14 @@ void main() { }); test('contains everyone, followersOnly and nobody', () { - expect(MessagesFrom.values, containsAll([ - MessagesFrom.everyone, - MessagesFrom.followersOnly, - MessagesFrom.nobody, - ])); + expect( + MessagesFrom.values, + containsAll([ + MessagesFrom.everyone, + MessagesFrom.followersOnly, + MessagesFrom.nobody, + ]), + ); }); }); }); diff --git a/test/features/settings/domain/entities/privacy_settings_entity_test.dart b/test/features/settings/domain/entities/privacy_settings_entity_test.dart index 6188d134..83bda782 100644 --- a/test/features/settings/domain/entities/privacy_settings_entity_test.dart +++ b/test/features/settings/domain/entities/privacy_settings_entity_test.dart @@ -46,8 +46,14 @@ void main() { test('overrides isPrivate only', () { final updated = tEntity.copyWith(isPrivate: true); expect(updated.isPrivate, true); - expect(updated.receiveMessageFromAnyone, tEntity.receiveMessageFromAnyone); - expect(updated.showActivitiesInDiscovery, tEntity.showActivitiesInDiscovery); + expect( + updated.receiveMessageFromAnyone, + tEntity.receiveMessageFromAnyone, + ); + expect( + updated.showActivitiesInDiscovery, + tEntity.showActivitiesInDiscovery, + ); expect(updated.showAsTopFan, tEntity.showAsTopFan); expect(updated.showTopFansOnTracks, tEntity.showTopFansOnTracks); }); @@ -85,8 +91,14 @@ void main() { expect(updated.isPrivate, true); expect(updated.showAsTopFan, false); expect(updated.showTopFansOnTracks, false); - expect(updated.receiveMessageFromAnyone, tEntity.receiveMessageFromAnyone); - expect(updated.showActivitiesInDiscovery, tEntity.showActivitiesInDiscovery); + expect( + updated.receiveMessageFromAnyone, + tEntity.receiveMessageFromAnyone, + ); + expect( + updated.showActivitiesInDiscovery, + tEntity.showActivitiesInDiscovery, + ); }); }); }); diff --git a/test/features/settings/presentation/providers/notification_prefs_notifier_test.dart b/test/features/settings/presentation/providers/notification_prefs_notifier_test.dart index 6b7da92e..88250530 100644 --- a/test/features/settings/presentation/providers/notification_prefs_notifier_test.dart +++ b/test/features/settings/presentation/providers/notification_prefs_notifier_test.dart @@ -50,9 +50,7 @@ void main() { mockRepo = MockSettingsRepoInterface(); registerFallbackValue(tNotifEntity); container = ProviderContainer( - overrides: [ - settingsRepositoryProvider.overrideWithValue(mockRepo), - ], + overrides: [settingsRepositoryProvider.overrideWithValue(mockRepo)], ); }); @@ -61,8 +59,9 @@ void main() { group('NotificationPrefsNotifier', () { group('build', () { test('loads preferences from repository on first read', () async { - when(() => mockRepo.getNotificationPreferences()) - .thenAnswer((_) async => tNotifEntity); + when( + () => mockRepo.getNotificationPreferences(), + ).thenAnswer((_) async => tNotifEntity); final result = await container.read(notificationPrefsProvider.future); @@ -71,8 +70,9 @@ void main() { }); test('calls getNotificationPreferences exactly once on build', () async { - when(() => mockRepo.getNotificationPreferences()) - .thenAnswer((_) async => tNotifEntity); + when( + () => mockRepo.getNotificationPreferences(), + ).thenAnswer((_) async => tNotifEntity); await container.read(notificationPrefsProvider.future); @@ -81,62 +81,80 @@ void main() { }); group('save', () { - test('optimistically applies updated entity then commits server response', () async { - when(() => mockRepo.getNotificationPreferences()) - .thenAnswer((_) async => tNotifEntity); - - final updated = tNotifEntity.copyWith(newFollowerPush: false); - final serverResponse = tNotifEntity.copyWith( - newFollowerPush: false, - newsletterEmail: false, - ); - - when(() => mockRepo.updateNotificationPreferences(any())) - .thenAnswer((_) async => serverResponse); - - await container.read(notificationPrefsProvider.future); - - await container.read(notificationPrefsProvider.notifier).save(updated); - - final state = container.read(notificationPrefsProvider); - expect(state.value, serverResponse); - verify(() => mockRepo.updateNotificationPreferences(any())).called(1); - }); - - test('reverts to previous state and emits AsyncError when update fails', () async { - when(() => mockRepo.getNotificationPreferences()) - .thenAnswer((_) async => tNotifEntity); - - when(() => mockRepo.updateNotificationPreferences(any())) - .thenThrow(Exception('Update failed')); - - await container.read(notificationPrefsProvider.future); - - final updated = tNotifEntity.copyWith(newFollowerPush: false); - await container.read(notificationPrefsProvider.notifier).save(updated); - - final state = container.read(notificationPrefsProvider); - expect(state, isA()); - }); + test( + 'optimistically applies updated entity then commits server response', + () async { + when( + () => mockRepo.getNotificationPreferences(), + ).thenAnswer((_) async => tNotifEntity); + + final updated = tNotifEntity.copyWith(newFollowerPush: false); + final serverResponse = tNotifEntity.copyWith( + newFollowerPush: false, + newsletterEmail: false, + ); + + when( + () => mockRepo.updateNotificationPreferences(any()), + ).thenAnswer((_) async => serverResponse); + + await container.read(notificationPrefsProvider.future); + + await container + .read(notificationPrefsProvider.notifier) + .save(updated); + + final state = container.read(notificationPrefsProvider); + expect(state.value, serverResponse); + verify(() => mockRepo.updateNotificationPreferences(any())).called(1); + }, + ); + + test( + 'reverts to previous state and emits AsyncError when update fails', + () async { + when( + () => mockRepo.getNotificationPreferences(), + ).thenAnswer((_) async => tNotifEntity); + + when( + () => mockRepo.updateNotificationPreferences(any()), + ).thenThrow(Exception('Update failed')); + + await container.read(notificationPrefsProvider.future); + + final updated = tNotifEntity.copyWith(newFollowerPush: false); + await container + .read(notificationPrefsProvider.notifier) + .save(updated); + + final state = container.read(notificationPrefsProvider); + expect(state, isA()); + }, + ); test('passes the updated entity to the repository', () async { - when(() => mockRepo.getNotificationPreferences()) - .thenAnswer((_) async => tNotifEntity); + when( + () => mockRepo.getNotificationPreferences(), + ).thenAnswer((_) async => tNotifEntity); final updated = tNotifEntity.copyWith( messagesFrom: MessagesFrom.nobody, newFollowerPush: false, ); - when(() => mockRepo.updateNotificationPreferences(any())) - .thenAnswer((_) async => updated); + when( + () => mockRepo.updateNotificationPreferences(any()), + ).thenAnswer((_) async => updated); await container.read(notificationPrefsProvider.future); await container.read(notificationPrefsProvider.notifier).save(updated); - final captured = verify( - () => mockRepo.updateNotificationPreferences(captureAny()), - ).captured.single as NotificationPreferencesEntity; + final captured = + verify( + () => mockRepo.updateNotificationPreferences(captureAny()), + ).captured.single + as NotificationPreferencesEntity; expect(captured.messagesFrom, MessagesFrom.nobody); expect(captured.newFollowerPush, false); }); diff --git a/test/features/settings/presentation/providers/privacy_settings_notifier_test.dart b/test/features/settings/presentation/providers/privacy_settings_notifier_test.dart index 6b24b2a8..2a5e9c45 100644 --- a/test/features/settings/presentation/providers/privacy_settings_notifier_test.dart +++ b/test/features/settings/presentation/providers/privacy_settings_notifier_test.dart @@ -34,9 +34,7 @@ void main() { mockRepo = MockSettingsRepoInterface(); registerFallbackValue(tPrivacyEntity); container = ProviderContainer( - overrides: [ - settingsRepositoryProvider.overrideWithValue(mockRepo), - ], + overrides: [settingsRepositoryProvider.overrideWithValue(mockRepo)], ); }); @@ -45,8 +43,9 @@ void main() { group('PrivacySettingsNotifier', () { group('build', () { test('loads privacy settings from repository on first read', () async { - when(() => mockRepo.getPrivacySettings()) - .thenAnswer((_) async => tPrivacyEntity); + when( + () => mockRepo.getPrivacySettings(), + ).thenAnswer((_) async => tPrivacyEntity); final result = await container.read(privacySettingsProvider.future); @@ -55,8 +54,9 @@ void main() { }); test('calls getPrivacySettings exactly once on build', () async { - when(() => mockRepo.getPrivacySettings()) - .thenAnswer((_) async => tPrivacyEntity); + when( + () => mockRepo.getPrivacySettings(), + ).thenAnswer((_) async => tPrivacyEntity); await container.read(privacySettingsProvider.future); @@ -65,94 +65,125 @@ void main() { }); group('save', () { - test('optimistically applies updated entity and keeps it after confirmed patch', () async { - when(() => mockRepo.getPrivacySettings()) - .thenAnswer((_) async => tPrivacyEntity); - - final updated = tPrivacyEntity.copyWith(receiveMessageFromAnyone: false); - const serverResponse = PrivacySettingsEntity( - isPrivate: false, - receiveMessageFromAnyone: false, - showActivitiesInDiscovery: true, - showAsTopFan: true, - showTopFansOnTracks: true, - ); - - when(() => mockRepo.updatePrivacySettings(any(), any())) - .thenAnswer((_) async => serverResponse); - - await container.read(privacySettingsProvider.future); - await container.read(privacySettingsProvider.notifier).save(updated); - - // Optimistic state is kept — server response is NOT used to overwrite state. - // This prevents fields absent from the PATCH response (defaulted by fromJson) - // from reverting toggles the user just set. - final state = container.read(privacySettingsProvider); - expect(state.value, updated); - verify(() => mockRepo.updatePrivacySettings(any(), any())).called(1); - }); - - test('keeps optimistic state when update throws (silent catch)', () async { - when(() => mockRepo.getPrivacySettings()) - .thenAnswer((_) async => tPrivacyEntity); - - when(() => mockRepo.updatePrivacySettings(any(), any())) - .thenThrow(Exception('Update failed')); - - await container.read(privacySettingsProvider.future); - - final updated = tPrivacyEntity.copyWith(receiveMessageFromAnyone: false); - await container.read(privacySettingsProvider.notifier).save(updated); - - // Error is swallowed — state stays as the optimistic update. - final state = container.read(privacySettingsProvider); - expect(state, isA>()); - expect(state.value, updated); - }); - - test('passes previous state as "old" and updated entity to repository', () async { - when(() => mockRepo.getPrivacySettings()) - .thenAnswer((_) async => tPrivacyEntity); - - final updated = tPrivacyEntity.copyWith(showAsTopFan: false); - - when(() => mockRepo.updatePrivacySettings(tPrivacyEntity, updated)) - .thenAnswer((_) async => updated); - - await container.read(privacySettingsProvider.future); - await container.read(privacySettingsProvider.notifier).save(updated); - - verify( - () => mockRepo.updatePrivacySettings(tPrivacyEntity, updated), - ).called(1); - }); - - test('uses updated entity as "old" when state has no data before save', () async { - // build returns normally so we get data first, then simulate a save - // while the state is already updated (second save scenario). - when(() => mockRepo.getPrivacySettings()) - .thenAnswer((_) async => tPrivacyEntity); - - final firstUpdate = tPrivacyEntity.copyWith(showAsTopFan: false); - final secondUpdate = firstUpdate.copyWith(showActivitiesInDiscovery: false); - - when(() => mockRepo.updatePrivacySettings(any(), any())) - .thenAnswer((_) async => firstUpdate); - - await container.read(privacySettingsProvider.future); - - // First save — state becomes firstUpdate after commit. - await container.read(privacySettingsProvider.notifier).save(firstUpdate); - - when(() => mockRepo.updatePrivacySettings(any(), any())) - .thenAnswer((_) async => secondUpdate); - - // Second save — previous is firstUpdate. - await container.read(privacySettingsProvider.notifier).save(secondUpdate); - - final state = container.read(privacySettingsProvider); - expect(state.value, secondUpdate); - }); + test( + 'optimistically applies updated entity and keeps it after confirmed patch', + () async { + when( + () => mockRepo.getPrivacySettings(), + ).thenAnswer((_) async => tPrivacyEntity); + + final updated = tPrivacyEntity.copyWith( + receiveMessageFromAnyone: false, + ); + const serverResponse = PrivacySettingsEntity( + isPrivate: false, + receiveMessageFromAnyone: false, + showActivitiesInDiscovery: true, + showAsTopFan: true, + showTopFansOnTracks: true, + ); + + when( + () => mockRepo.updatePrivacySettings(any(), any()), + ).thenAnswer((_) async => serverResponse); + + await container.read(privacySettingsProvider.future); + await container.read(privacySettingsProvider.notifier).save(updated); + + // Optimistic state is kept — server response is NOT used to overwrite state. + // This prevents fields absent from the PATCH response (defaulted by fromJson) + // from reverting toggles the user just set. + final state = container.read(privacySettingsProvider); + expect(state.value, updated); + verify(() => mockRepo.updatePrivacySettings(any(), any())).called(1); + }, + ); + + test( + 'keeps optimistic state when update throws (silent catch)', + () async { + when( + () => mockRepo.getPrivacySettings(), + ).thenAnswer((_) async => tPrivacyEntity); + + when( + () => mockRepo.updatePrivacySettings(any(), any()), + ).thenThrow(Exception('Update failed')); + + await container.read(privacySettingsProvider.future); + + final updated = tPrivacyEntity.copyWith( + receiveMessageFromAnyone: false, + ); + await container.read(privacySettingsProvider.notifier).save(updated); + + // Error is swallowed — state stays as the optimistic update. + final state = container.read(privacySettingsProvider); + expect(state, isA>()); + expect(state.value, updated); + }, + ); + + test( + 'passes previous state as "old" and updated entity to repository', + () async { + when( + () => mockRepo.getPrivacySettings(), + ).thenAnswer((_) async => tPrivacyEntity); + + final updated = tPrivacyEntity.copyWith(showAsTopFan: false); + + when( + () => mockRepo.updatePrivacySettings(tPrivacyEntity, updated), + ).thenAnswer((_) async => updated); + + await container.read(privacySettingsProvider.future); + await container.read(privacySettingsProvider.notifier).save(updated); + + verify( + () => mockRepo.updatePrivacySettings(tPrivacyEntity, updated), + ).called(1); + }, + ); + + test( + 'uses updated entity as "old" when state has no data before save', + () async { + // build returns normally so we get data first, then simulate a save + // while the state is already updated (second save scenario). + when( + () => mockRepo.getPrivacySettings(), + ).thenAnswer((_) async => tPrivacyEntity); + + final firstUpdate = tPrivacyEntity.copyWith(showAsTopFan: false); + final secondUpdate = firstUpdate.copyWith( + showActivitiesInDiscovery: false, + ); + + when( + () => mockRepo.updatePrivacySettings(any(), any()), + ).thenAnswer((_) async => firstUpdate); + + await container.read(privacySettingsProvider.future); + + // First save — state becomes firstUpdate after commit. + await container + .read(privacySettingsProvider.notifier) + .save(firstUpdate); + + when( + () => mockRepo.updatePrivacySettings(any(), any()), + ).thenAnswer((_) async => secondUpdate); + + // Second save — previous is firstUpdate. + await container + .read(privacySettingsProvider.notifier) + .save(secondUpdate); + + final state = container.read(privacySettingsProvider); + expect(state.value, secondUpdate); + }, + ); }); }); }